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
@@ -289,3 +289,42 @@ development and transfer data: it cannot qualify production Node support, and No
cannot qualify PHP or Python. No capability is enabled, no defect is admitted, no alert is
emitted and no latency claim is made. Runtime, independent qualification and rollout all remain
NO-GO.
## M1b explanation: the finding as a developer would read it
Run 2026-09-16 after task 10.10, same pins and budget, with an unreviewed experimental
declaration generated by the instrument and bound to the run's own analysis semantics.
Evidence: [summary](../../../benchmarks/measurements/2026-09-16-nodegoat-m1b-explained.json)
and its [bundle](../../../benchmarks/measurements/2026-09-16-nodegoat-m1b-explained.json.gz).
Three evaluation findings were rendered on the vulnerable revision, one per eval site. For
line 32:
- **Location** app/routes/contributions.js:32, family injection, novelty new with
`operation_absent_from_comparable_base`.
- **Witness** `eval(req.body.preTax) -> const preTax = eval(req.body.preTax)`.
- **Change attribution** `base:app/routes/contributions.js:32-34`.
- **Provenance** `file_scope`: the question named no function, the engine reported `<lambda>2`.
- **Consequence** "A request field reaches const preTax = eval(req.body.preTax), where it is
evaluated as JavaScript, so an attacker controlling that field can execute arbitrary code in
the server process."
- **Repair** "Replace the evaluation at const preTax = eval(req.body.preTax) with a numeric
conversion such as Number(...) or parseInt(...), then validate the result before use."
Both sentences come from the declaration's templates through the grounded-template path, not from
generated prose. The declaration reports verdict `experimental` and `enabled: false`, the
production result still reports `finding_status: none`, and all three artifacts carry zero
admitted defects. The rendering path never reaches admission, so no supplied declaration can
change that.
### A defect this surfaced, recorded not repaired
The operation symbol admission extracts is the text before the first parenthesis, which for
`const preTax = eval(req.body.preTax)` is `const preTax = eval`, not `eval`. The demonstration
declaration had to enumerate each assignment spelling for its coverage check to pass. A real
qualified declaration cannot enumerate every assignment a codebase might write, so admission
would reject genuine findings on `operation_semantics_mismatch`. That is recorded as its own task
ahead of M4; it is not worked around here.
M1b is met: the comparison classifies all three revisions through the production rules, and the
finding reads as a developer would need it to, with every claim traceable to its evidence.
+57 -2
View File
@@ -266,7 +266,7 @@ Preserve all completed task evidence below.
- _Requirements: 1.1, 2.1, 2.4, 3.4, 6.3, 7.1, 7.3_
- _Verified 2026-09-16:_ all three pinned revisions replayed through the production CLI under a named 900-second development budget; expectations frozen on disk first. Raw `<lambda>2` witnesses present at lines 32–34; fixed twin analyzed and quiet (target question answered with zero rows, no deadline reason, three base-only findings); unchanged revision analyzed and recorded `unchanged`. Five vetoes fired and were recorded; the remaining blocker is `operation_correspondence_unresolved` for an operation on a changed line. Zero admitted defects. Evidence: [M1a record](m1a-recorded-vetoes.md) and `benchmarks/measurements/2026-09-16-nodegoat-m1a-recorded-vetoes.json`. Diagnosis only; no capability enabled and no latency claim.
- [ ] 10. Repair only the vetoes M1a recorded (release milestone M1b)
- [x] 10. Repair only the vetoes M1a recorded (release milestone M1b)
- [x] 10.1 Establish operation correspondence for an operation on a changed line
- M1a measured this as the blocker that survives every other repair: a newly introduced operation sits on a changed line, has no base line anchor, and therefore cannot be classified even when every completeness veto is lifted.
- Bind the required base/head correspondence to the specific operation identity rather than to a line anchor. A complete base answer for the counterpart question that contains no operation with the same operation and source establishes absence; a matching operation elsewhere in that answer is movement, not novelty.
@@ -341,7 +341,7 @@ Preserve all completed task evidence below.
- _Requirements: 1.1, 2.1, 2.4, 3.1, 3.4, 6.3, 7.1, 7.3_
- _Verified 2026-09-16 with one named exception:_ all three revisions replayed through the production rules with exact pins and reopened source receipts. Vulnerable: three sites new with `operation_absent_from_comparable_base`, admission rejecting on `capability_unavailable` alone. Fixed twin: analyzed and quiet with three base-only findings. Unchanged: three sites `unchanged` with base locations resolved to the same lines. Every finding carries its question, the engine-reported method and its witness; the head census is the complete 44 files; all 436 base questions complete. Zero admitted defects. The repaired and remaining veto lists are in [the M1b record](m1a-recorded-vetoes.md). **Exception:** the supported security consequence and concrete repair direction are not demonstrated, because both are owned by an evaluated capability declaration and the registry is deliberately empty. M1b's comparison half is verified; its explanation half waits on M4.
- [ ] 10.10 Render the experimental evaluation finding with its consequence and repair
- [x] 10.10 Render the experimental evaluation finding with its consequence and repair
- **The remaining half of M1b's exit, named by the 10.9 run.** The comparison is verified, but the milestone also asks for a developer-readable finding carrying a supported security consequence and a concrete repair direction. Both come from an evaluated capability declaration and the registry is deliberately empty, so nothing renders them.
- Generate an explicitly unreviewed, experimental declaration for this capability with the existing registry tool, and render the finding through the same grounded-template path the admitted case uses. Keep eligibility disabled: an experimental declaration is not enabled and not PASS, so admission must still reject it and no normal hook alert may be advertised.
- Do not write consequence or repair text that no declaration supports. Refusing generic advice without a supported context and operation is the rule admission exists to enforce, and the experimental path must not become a way around it.
@@ -349,6 +349,61 @@ Preserve all completed task evidence below.
- _Boundary: Result and Hook Adapter, Delta and Actionability Policy, Evaluation harness_
- _Depends: 10.9_
- _Requirements: 3.1, 3.2, 3.3, 5.1, 5.5, 6.3_
- _Verified 2026-09-16:_ 1,386 tests passed, nine skipped; Ruff/format/mypy passed. Three evaluation findings rendered on the vulnerable revision with exact location, witness, change attribution, provenance, consequence and repair, all from the declaration's templates through the grounded-template path. The declaration reports verdict experimental and enabled false, the production result still reports no actionable defect, and all three artifacts carry zero admitted defects. Controls prove an ungrounded template is refused, an uncovered operation symbol is stated, no matching declaration leaves both sentences absent with the reason, and a declaration claiming PASS still admits nothing because the rendering path is outside admission. Evidence: `benchmarks/measurements/2026-09-16-nodegoat-m1b-explained.json`.
- [ ] 11. Fit the same useful analysis into the push budget (release milestone M2)
- [ ] 11.1 Give the backend a transaction-owned engine session
- The measured cost is JVM startup, not analysis. In the M1b runs each disposable `joern --script` invocation cost 13 to 23 seconds while the work itself took under a second, and the 2026-09-14 lifecycle probe measured one session at 12.8 s to start, 3.6 s to load a graph and 0.22 to 0.47 s per warm request.
- Own the session inside the existing backend for the length of one transaction: loopback only on an ephemeral port with no published port, a private working directory, one shared deadline, serialized requests, explicit graph identity and epoch, and kill-and-reap of the whole process group on cancellation, failure or exit.
- An HTTP status is not an answer contract. Require a per-request receipt written to a fresh private file and verified before the answer is accepted, the way the probe's repaired instrument did after its first attempt read an empty stdout as a census.
- Poison the session on any crash or malformed response so a failed session can never answer a later request, and fall back to the disposable path rather than reporting a zero.
- Done when a controlled hanging in-session request is cancelled inside the allowance with no surviving process group, a crashed session answers nothing afterwards, and the disposable path still works unchanged.
- _Boundary: contributor-scan runtime, Cache and Engine Boundary_
- _Requirements: 4.1, 4.2, 7.3_
- [ ] 11.2 Prove every shipped query kind answers identically through both transports
- Run taint, dominance, configuration and census over the same graph through the session and through the disposable path and require identical payloads, including empty answers and the distinction between an empty answer and an unavailable one.
- Cover a malformed or absent response, a switched graph, a second graph loaded in the same session, and a request arriving after cancellation. Equivalence on a census alone does not establish equivalence for security queries.
- Done when every shipped query kind matches byte for byte on a real graph, and every failure shape is reported as unavailable rather than empty.
- _Boundary: contributor-scan runtime, engine integration tests_
- _Depends: 11.1_
- _Requirements: 4.1, 4.2, 6.4, 7.3_
- [ ] 11.3 Route the batch path through the session behind an explicit setting
- Consume the session from the existing batch and census paths, keeping the disposable path as the default and as the fallback. No capability, ranking decision or evidence semantics changes; only the transport does.
- Account for session startup, load, requests and teardown inside the one shared deadline, and record them as separate stage costs so the next decision is made on measurements rather than estimates.
- Done when a replay produces the same evidence and admission through either transport under a sufficient budget, with per-stage costs recorded for both.
- _Boundary: Cache and Engine Boundary, Push runner_
- _Depends: 11.2_
- _Requirements: 4.1, 4.3, 6.4, 7.3_
- [ ] 11.4 Remove the startup cost from the critical path where the measurement says it pays
- With 11.3's stage costs in hand, decide what else to change. The candidates the M1b runs point at are the overlay pass, 15.8 s in a separate invocation, and session startup, 12.8 s, which may overlap the frontend build rather than follow it.
- Change only what the measurement implicates, and keep each change's correctness evidence separate from its timing evidence. Do not substitute a faster census for a completed security check.
- Done when the recorded stage costs after the change are compared against the same profile before it, with any regression in completed checks reported rather than traded away.
- _Boundary: contributor-scan runtime_
- _Depends: 11.3_
- _Requirements: 4.1, 4.2, 6.2, 6.5_
- [ ] 11.5 Rerun the frozen seven-class runtime profile and decide
- Rerun the unchanged seven-class profile: identical-tip, function edit, dependency edit, configuration edit, cold, growth and multi-ref, three samples each, retaining every timeout and unresolved check. Identical-tip reuse cannot substitute for warm changed-code work.
- Verify the M1 outcome still holds on the same three revisions through the production rules, so speed is not bought with a lost detection.
- Apply the existing gates without alteration: representative warm changed-code p95 at most 30 s, cancellation and reporting at most 2 s, supported-check completion at least 95%. A failed gate stays NO-GO and names the dominant remaining cost.
- Done when one versioned scorecard reports p50 and p95 with sample counts beside the M1 correctness outcome, and states GO or NO-GO against the unchanged gates.
- _Boundary: Evaluation harness, runtime integration_
- _Depends: 11.4_
- _Requirements: 4.1, 4.2, 6.2, 6.3, 6.5, 7.3_
- [ ] 12. Prerequisites for independent qualification found during M1a and M1b
- [ ] 12.1 Extract the operation symbol from the operation, not from the statement
- **Found 2026-09-16 in the M1b explanation run.** Admission takes the text before the first parenthesis as the operation symbol, so `const preTax = eval(req.body.preTax)` yields `const preTax = eval`. The demonstration declaration had to enumerate each assignment spelling for its coverage check to pass.
- A qualified declaration cannot enumerate every assignment a codebase might write, so admission would reject genuine findings on `operation_semantics_mismatch`. Take the symbol from the called operation the engine reported, preserving receiver spelling, and leave an unresolved or dynamic spelling ineligible as it is today.
- Done when the symbol for an assigned call is the call's own name, a receiver-qualified call keeps its receiver, a dynamic spelling stays ineligible, and a declaration naming the plain symbol covers the assigned form.
- _Boundary: Delta and Actionability Policy_
- _Requirements: 3.1, 3.2, 3.4_
- [ ] 12.2 Supply per-family context producers for dominance and configuration
- The M1b exit run left 231 of 436 head questions reporting `change_context_incomplete` from `context_projection_unavailable` and `context_scope_empty`, because those families' queries export no context. They did not block the Node injection case, so M1b correctly left them alone, but their families stay experimental until they can answer.
- Give dominance and configuration their own context producers describing the actual scope of the query they accompany. Do not copy taint context into them: dominance evaluates sibling operations across a file and configuration has different scope and obligations.
- Done when a dominance and a configuration question complete their change context on a real repository, with missing extents and unsupported reasoning still explicit.
- _Boundary: contributor-scan query/context evidence_
- _Requirements: 2.1, 2.2, 8.1, 8.2_
## Implementation Notes
@@ -1,7 +1,7 @@
{
"modules": 116,
"modules": 117,
"counts": {
"load_bearing": 107,
"load_bearing": 108,
"standalone_capability": 9
},
"orphaned": [],
@@ -200,6 +200,14 @@
"cpg.backend"
]
},
{
"module": "cpg.session",
"classification": "load_bearing",
"reason": "reachable from gate, map_gate, pair_gate or the CLI",
"importers": [
"cpg.backend"
]
},
{
"module": "findings",
"classification": "load_bearing",
@@ -378,6 +386,7 @@
"importers": [
"cli",
"cpg.backend",
"cpg.session",
"model.candidates",
"model.config_value",
"model.dominance",
@@ -440,6 +449,7 @@
"importers": [
"cli",
"cpg.backend",
"cpg.session",
"model.partitions",
"model.regions",
"model.scan",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
{
"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": [
{
"mechanism": "main_annotation_kept",
"definition_sent": true,
"definition_output": "def exec(cpgFile: String): Unit\n",
"call_seconds": 2.0279543119249865,
"payload": {
"files": "19",
"methods": "229",
"calls": "1683",
"file_names": [
"assets/js/chart/chart-data-morris.js",
"assets/js/tour/redirects-steps.js",
"data/allocations-dao.js",
"data/benefits-dao.js",
"data/contributions-dao.js",
"data/memos-dao.js",
"data/profile-dao.js",
"data/research-dao.js",
"data/user-dao.js",
"routes/allocations.js",
"routes/benefits.js",
"routes/contributions.js",
"routes/error.js",
"routes/index.js",
"routes/memos.js",
"routes/profile.js",
"routes/research.js",
"routes/session.js",
"routes/tutorial.js"
],
"overlays": "base,controlflow,typerel,callgraph,dataflowOss",
"maxHeapMB": "1536"
},
"stdout_tail": "llocations.js\",\"routes/benefits.js\",\"routes/contributions.js\",\"routes/error.js\",\"routes/index.js\",\"routes/memos.js\",\"routes/profile.js\",\"routes/research.js\",\"routes/session.js\",\"routes/tutorial.js\"],\"overlays\":\"base,controlflow,typerel,callgraph,dataflowOss\",\"maxHeapMB\":\"1536\"}\n---OUSAST-CPG-END---\n",
"payload_matches_disposable": true,
"seconds": 3.2417371519841254
},
{
"mechanism": "main_annotation_stripped",
"definition_sent": true,
"definition_output": "def ousastCensus(cpgFile: String): Unit\n",
"call_seconds": 0.7982574889902025,
"payload": {
"files": "19",
"methods": "229",
"calls": "1683",
"file_names": [
"assets/js/chart/chart-data-morris.js",
"assets/js/tour/redirects-steps.js",
"data/allocations-dao.js",
"data/benefits-dao.js",
"data/contributions-dao.js",
"data/memos-dao.js",
"data/profile-dao.js",
"data/research-dao.js",
"data/user-dao.js",
"routes/allocations.js",
"routes/benefits.js",
"routes/contributions.js",
"routes/error.js",
"routes/index.js",
"routes/memos.js",
"routes/profile.js",
"routes/research.js",
"routes/session.js",
"routes/tutorial.js"
],
"overlays": "base,controlflow,typerel,callgraph,dataflowOss",
"maxHeapMB": "1536"
},
"stdout_tail": "llocations.js\",\"routes/benefits.js\",\"routes/contributions.js\",\"routes/error.js\",\"routes/index.js\",\"routes/memos.js\",\"routes/profile.js\",\"routes/research.js\",\"routes/session.js\",\"routes/tutorial.js\"],\"overlays\":\"base,controlflow,typerel,callgraph,dataflowOss\",\"maxHeapMB\":\"1536\"}\n---OUSAST-CPG-END---\n",
"payload_matches_disposable": true,
"seconds": 1.0805925760651007
}
],
"verdict": {
"status": "PASS",
"working": [
"main_annotation_kept",
"main_annotation_stripped"
],
"warm_call_seconds": [
2.0279543119249865,
0.7982574889902025
],
"disposable_seconds": 9.838564986013807,
"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."
},
"source": {
"root": "/cache/nodegoat/c5cb68a7084e/app",
"files": 25,
"bytes": 318000,
"sha256": "459f6fd19c7d4700094f0b834eb5474ff94923014055c22385de9de3332c9d53"
},
"build": {
"exit_code": 0,
"graph_bytes": 425453,
"stderr": "Picked up JAVA_TOOL_OPTIONS: -Xmx1536m\n"
},
"disposable": {
"seconds": 9.838564986013807,
"exit_code": 0,
"payload": {
"files": "19",
"methods": "229",
"calls": "1683",
"file_names": [
"assets/js/chart/chart-data-morris.js",
"assets/js/tour/redirects-steps.js",
"data/allocations-dao.js",
"data/benefits-dao.js",
"data/contributions-dao.js",
"data/memos-dao.js",
"data/profile-dao.js",
"data/research-dao.js",
"data/user-dao.js",
"routes/allocations.js",
"routes/benefits.js",
"routes/contributions.js",
"routes/error.js",
"routes/index.js",
"routes/memos.js",
"routes/profile.js",
"routes/research.js",
"routes/session.js",
"routes/tutorial.js"
],
"overlays": "base,controlflow,typerel,callgraph,dataflowOss",
"maxHeapMB": "1536"
}
},
"session_startup_seconds": 9.547755968989804,
"milestone": "M2 task 11.1",
"engine_findings": [
"runScript is absent from the server REPL, so a script cannot be invoked by name.",
"The server answers success even for a compile error and returns the diagnostic in its body, so an HTTP status is worthless as an error signal and the receipt file is the only proof.",
"A shipped @main script cannot be nested inside a block expression; definitions must go to the top level unwrapped, with or without the annotation.",
"The Joern REPL shadows Console with io.joern.console.Console, which has no withOut, so output capture must fully qualify scala.Console.withOut."
],
"limits": [
"Census script on one JavaScript graph. Taint, dominance and configuration equivalence is task 11.2.",
"Timings are from one machine under no controlled load; they are not the hook latency gate."
]
}
+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())
+93 -29
View File
@@ -36,6 +36,7 @@ from typing import Any, Protocol
from ..model.contracts import ExecutionBudget
from .artifact import GraphArtifact, GraphCensus, GraphIdentity
from .session import EngineSession
logger = logging.getLogger(__name__)
@@ -320,9 +321,15 @@ class JoernBackend:
# Why the last build refused, so the driver can name it instead of reporting a bare `cpg_build_failed`.
last_failure: str = ""
execution_budget: ExecutionBudget | None = None
# M2 11.3: opt-in transport. Off by default, so shipped behaviour stays the disposable
# invocation this project has measured. A session that cannot answer falls back to it rather
# than reporting a zero, and what a session returns is the same payload or nothing at all.
session_transport: bool = False
_diagnostics: list[str] = field(default_factory=list, init=False, repr=False)
_scratch: list[Path] = field(default_factory=list, init=False, repr=False)
_cancel_deadline: float | None = field(default=None, init=False, repr=False)
_session: EngineSession | None = field(default=None, init=False, repr=False)
_defined: set[str] = field(default_factory=set, init=False, repr=False)
def available(self) -> bool:
from .capability import has_cpg
@@ -1038,24 +1045,29 @@ class JoernBackend:
except OSError as exc:
logger.warning("could not stage cpg batch %s: %s", query, exc)
return None
command = [
shutil.which("joern") or "joern",
self._heap_flag(),
"--script",
str(script),
"--param",
f"cpgFile={cpg_path}",
"--param",
f"requestsFile={requests_file}",
]
for name, value in shared.items():
command += ["--param", f"{name}={value}"]
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
if completed is None or completed.returncode != 0:
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
logger.warning("cpg batch %s failed: %s", query, detail)
return None
parsed = extract_payload(completed.stdout or "")
# The session answers the same script with the same parameters, or nothing; nothing means
# the disposable invocation below runs exactly as it always has.
body = self._session_payload(cpg_path, query, {"cpgFile": str(cpg_path), "requestsFile": str(requests_file), **shared})
if body is None:
command = [
shutil.which("joern") or "joern",
self._heap_flag(),
"--script",
str(script),
"--param",
f"cpgFile={cpg_path}",
"--param",
f"requestsFile={requests_file}",
]
for name, value in shared.items():
command += ["--param", f"{name}={value}"]
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
if completed is None or completed.returncode != 0:
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
logger.warning("cpg batch %s failed: %s", query, detail)
return None
body = completed.stdout or ""
parsed = extract_payload(body)
if not self._time_available():
return None
if not isinstance(parsed, Mapping):
@@ -1075,19 +1087,71 @@ class JoernBackend:
if not script.is_file():
logger.warning("no such cpg query: %s", script)
return None
command = [shutil.which("joern") or "joern", self._heap_flag(), "--script", str(script), "--param", f"cpgFile={cpg_path}"]
for key, value in sorted(params.items()):
command += ["--param", f"{key}={_render(value)}"]
# Joern writes a `workspace/` beside the working directory; run it inside the CPG's own scratch dir so
# it can never land in the repository being analysed.
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
if completed is None or completed.returncode != 0:
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
logger.warning("cpg query %s failed: %s", query, detail)
return None
parsed = extract_payload(completed.stdout or "")
body = self._session_payload(cpg_path, query, {"cpgFile": str(cpg_path), **params})
if body is None:
command = [shutil.which("joern") or "joern", self._heap_flag(), "--script", str(script), "--param", f"cpgFile={cpg_path}"]
for key, value in sorted(params.items()):
command += ["--param", f"{key}={_render(value)}"]
# Joern writes a `workspace/` beside the working directory; run it inside the CPG's own scratch dir so
# it can never land in the repository being analysed.
completed = self._run(command, timeout=self.query_timeout, cwd=cpg_path.parent)
if completed is None or completed.returncode != 0:
detail = (completed.stderr or "")[-400:] if completed is not None else "timeout"
logger.warning("cpg query %s failed: %s", query, detail)
return None
body = completed.stdout or ""
parsed = extract_payload(body)
return parsed if self._time_available() else None
def _session_for(self, cpg_path: Path) -> EngineSession | None:
"""The transaction's session, started on first use. None means use the disposable path."""
if not self.session_transport or self.execution_budget is None:
return None
if self._session is not None:
return self._session if self._session.alive else None
session = EngineSession(cpg_path.parent / "engine-session", self.execution_budget, self._jvm_env())
if not session.start():
self._note("session_unavailable:" + (session.failure or "unknown"))
session.close()
self._session = session
return None
self._session = session
return session
def _session_payload(self, cpg_path: Path, query: str, params: Mapping[str, object]) -> str | None:
"""Run one shipped script in-session and return what it printed, or None to fall back.
The script is defined under a name of its own because every script declares `exec`, and a
second definition would shadow the first. Defining proves nothing: the receipt on the call
is the only proof, which is why any failure here simply falls back to the disposable path.
"""
session = self._session_for(cpg_path)
if session is None:
return None
try:
source = (self.queries_dir / f"{query}.sc").read_text()
except OSError:
return None
symbol = f"ousast_{query}"
if query not in self._defined:
if session.define(source.replace("@main def exec(", f"def {symbol}(", 1), timeout=self.query_timeout) is None:
self._note("session_define_failed:" + query)
return None
self._defined.add(query)
arguments = ", ".join(f"{name} = {json.dumps(_render(value))}" for name, value in sorted(params.items()))
answer = session.evaluate(f"{symbol}({arguments})", timeout=self.query_timeout)
if answer is None:
self._note("session_query_failed:" + query + ":" + (session.failure or "unknown"))
return None
return answer.stdout
def close_session(self) -> None:
"""End the transaction's session and reap its process group."""
if self._session is not None:
self._session.close()
self._session = None
self._defined.clear()
def _note(self, reason: str) -> None:
if reason not in self._diagnostics:
self._diagnostics.append(reason)
+278
View File
@@ -0,0 +1,278 @@
"""A transaction-owned Joern interpreter session on loopback (release milestone M2).
Measured on this project: JVM startup, not analysis, is what a push scan spends its time on.
Each disposable ``joern --script`` invocation costs 13 to 23 seconds while the work itself takes
under a second, and one session starts in about 13 seconds and then answers in well under one.
What this module does NOT do is decide anything. It is a transport. Every answer it returns is
the same payload the disposable path would have produced, or nothing at all, and the caller keeps
its existing fresh-build and disposable-query paths as the default and as the fallback.
Three properties are load-bearing, and each exists because its absence has already produced a
wrong answer somewhere in this project:
* **An HTTP status is not an answer.** The lifecycle probe's first attempt received
``success=true`` with an empty body because the payload went to the server's own log, and a
census of nothing would have been read as a census. So every request writes its output to a
fresh private file under a nonce, and the nonce is verified before the answer is accepted.
* **A poisoned session answers nothing.** A session that crashed, timed out or returned an
unparseable body is marked failed, and every later request on it returns ``None`` rather than
an answer from an unknown state.
* **The process group dies, not just the launcher.** ``joern`` is a shell script that starts a
JVM. Killing the script leaves the JVM holding its heap; this project has already run a machine
out of memory that way. Cancellation signals the group and waits inside the allowance.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import socket
import subprocess
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from openultrasast.model.contracts import ExecutionBudget
logger = logging.getLogger(__name__)
STARTUP_PROBE_SECONDS = 5.0
STARTUP_POLL_SECONDS = 0.25
def _free_port() -> int:
"""Let the OS choose, so concurrent transactions cannot collide on a fixed port."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return int(probe.getsockname()[1])
@dataclass
class SessionAnswer:
"""One verified answer: the stdout the evaluated code produced, and what it cost."""
stdout: str
seconds: float
@dataclass
class EngineSession:
"""One Joern server owned by one transaction. Not reusable across transactions."""
scratch: Path
budget: ExecutionBudget
env: dict[str, str]
joern: str = "joern"
startup_timeout_seconds: float = 180.0
failure: str = ""
startup_seconds: float = 0.0
loaded_graph: str = ""
requests: int = 0
# What the engine printed for the most recent request. The server answers `success` even for a
# compile error and puts the diagnostic here, so discarding it leaves a missing receipt with no
# explanation and nothing to debug. Kept for exactly that reason.
last_body: str = ""
_process: subprocess.Popen[str] | None = field(default=None, init=False, repr=False)
_port: int = field(default=0, init=False, repr=False)
_log: Any = field(default=None, init=False, repr=False)
@property
def alive(self) -> bool:
return not self.failure and self._process is not None and self._process.poll() is None
def _poison(self, reason: str) -> None:
if not self.failure:
self.failure = reason
logger.warning("engine session poisoned: %s", reason)
def _remaining(self, limit: float) -> float:
return min(limit, self.budget.deadline_monotonic - time.monotonic())
def start(self) -> bool:
"""Launch on loopback with an OS-chosen port. No published port, no network exposure."""
if self._process is not None:
return self.alive
if self._remaining(self.startup_timeout_seconds) <= 0:
self._poison("deadline_exhausted")
return False
self.scratch.mkdir(parents=True, exist_ok=True)
self._port = _free_port()
started = time.monotonic()
try:
self._log = (self.scratch / "session.log").open("w")
self._process = subprocess.Popen( # noqa: S603 -- fixed argument array
[self.joern, "--server", "--server-host", "127.0.0.1", "--server-port", str(self._port)],
cwd=str(self.scratch),
env=self.env,
stdout=self._log,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
except (OSError, subprocess.SubprocessError) as error:
self._poison("session_launch_failed:" + type(error).__name__)
return False
while True:
if self._process.poll() is not None:
self._poison("session_exited_during_startup")
return False
remaining = self._remaining(self.startup_timeout_seconds - (time.monotonic() - started))
if remaining <= 0:
self._poison("session_startup_timeout")
return False
if self._post("1", timeout=min(STARTUP_PROBE_SECONDS, remaining)) is not None:
self.startup_seconds = time.monotonic() - started
return True
time.sleep(STARTUP_POLL_SECONDS)
def _post(self, code: str, *, timeout: float) -> dict[str, object] | None:
"""One synchronous request. A transport failure is not an answer."""
if timeout <= 0:
return None
payload = json.dumps({"query": code}).encode()
call = urllib.request.Request(
f"http://127.0.0.1:{self._port}/query-sync", data=payload, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(call, timeout=timeout) as response: # noqa: S310 -- fixed loopback URL
body = json.load(response)
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
self.last_body = ""
return None
if not isinstance(body, dict):
self.last_body = ""
return None
self.last_body = str(body.get("stdout", ""))
return body
def evaluate(self, code: str, *, timeout: float) -> SessionAnswer | None:
"""Evaluate Scala in the session and return the output it printed, verified by receipt.
The evaluated code's console output is redirected into a private file whose first line is
a nonce this call generated. An answer is accepted only when that nonce comes back, so an
empty body, a cached file or another request's output cannot be read as this answer.
"""
if not self.alive:
self._poison(self.failure or "session_not_running")
return None
remaining = self._remaining(timeout)
if remaining <= 0:
self._poison("deadline_exhausted")
return None
nonce = uuid.uuid4().hex
target = self.scratch / f"answer-{nonce}.txt"
wrapped = _redirected(code, target, nonce)
started = time.monotonic()
body = self._post(wrapped, timeout=remaining)
seconds = time.monotonic() - started
self.requests += 1
if body is None:
self._poison("session_request_failed")
return None
if body.get("success") not in (True, "true"):
self._poison("session_request_unsuccessful")
return None
try:
written = target.read_text()
except OSError:
self._poison("session_answer_missing")
return None
finally:
target.unlink(missing_ok=True)
first, _, rest = written.partition("\n")
if first.strip() != nonce:
# A body that does not carry this call's receipt is somebody else's output, or none.
self._poison("session_receipt_mismatch")
return None
return SessionAnswer(rest, seconds)
def define(self, code: str, *, timeout: float) -> str | None:
"""Send code that must reach the REPL's top level, such as a definition.
A definition cannot be nested inside the receipt wrapper: `@main` and top-level
declarations are not block expressions, and wrapping them fails to compile. So this path
has no receipt, and its return value is therefore NOT proof that anything was defined.
The server answers `success` even for a compile error and puts the diagnostic in its body,
which is exactly why every real answer goes through `evaluate` and its receipt instead.
Returns the body the engine printed, or None when the request itself failed.
"""
if not self.alive:
self._poison(self.failure or "session_not_running")
return None
remaining = self._remaining(timeout)
if remaining <= 0:
self._poison("deadline_exhausted")
return None
body = self._post(code, timeout=remaining)
self.requests += 1
if body is None:
self._poison("session_request_failed")
return None
return str(body.get("stdout", ""))
def load(self, graph: Path, *, timeout: float) -> bool:
"""Load one graph, recording which is resident so a stale `cpg` cannot answer.
Loading a second graph replaces the first. The recorded identity is what lets the caller
refuse to answer a question about a graph that is not the one loaded.
"""
answer = self.evaluate(f'importCpg({json.dumps(str(graph))}); "loaded"', timeout=timeout)
if answer is None:
self.loaded_graph = ""
return False
self.loaded_graph = str(graph)
return True
def close(self) -> None:
"""Kill the group and reap it inside the cancellation allowance."""
process, self._process = self._process, None
if process is not None and process.poll() is None:
end = time.monotonic() + self.budget.cancellation_allowance_seconds
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except OSError:
self._poison("session_cleanup_incomplete")
try:
process.wait(timeout=max(0.0, end - time.monotonic()))
except subprocess.TimeoutExpired:
self._poison("session_cleanup_incomplete")
for stream in (process.stdout if process else None, process.stderr if process else None):
if stream is not None:
stream.close()
if self._log is not None:
self._log.close()
self._log = None
def __enter__(self) -> EngineSession:
return self
def __exit__(self, *_: object) -> None:
self.close()
def _redirected(code: str, target: Path, nonce: str) -> str:
"""Wrap `code` so its console output lands in `target` behind this call's receipt line.
The receipt is written before the code runs, so a body that never executed leaves a file
whose payload is empty rather than a file that does not exist: the two are distinguishable.
"""
path = json.dumps(str(target))
return (
"{ val __ousastPath = java.nio.file.Path.of(" + path + "); "
"java.nio.file.Files.writeString(__ousastPath, " + json.dumps(nonce + "\n") + "); "
"val __ousastOut = new java.io.FileOutputStream(" + path + ", true); "
# `scala.Console`, fully qualified: the Joern REPL shadows `Console` with
# `io.joern.console.Console`, which has no `withOut`, and the resulting compile error
# arrives as a successful response with an empty receipt.
"try { scala.Console.withOut(new java.io.PrintStream(__ousastOut, true)) { " + code + " } } "
'finally { __ousastOut.close() }; "ok" }'
)
+220
View File
@@ -0,0 +1,220 @@
"""M2 11.1: the session is a transport whose failures are never answers."""
from __future__ import annotations
import os
import signal
import sys
import textwrap
import time
from pathlib import Path
import pytest
from openultrasast.cpg.session import EngineSession, _redirected
from openultrasast.model.contracts import ExecutionBudget
FAKE = textwrap.dedent(
"""
import json, os, sys, time
from http.server import BaseHTTPRequestHandler, HTTPServer
MODE = os.environ.get("FAKE_MODE", "ok")
PORT = int(sys.argv[sys.argv.index("--server-port") + 1])
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
code = body["query"]
if MODE == "hang" and "ousastHang" in code:
time.sleep(120)
if MODE == "crash" and "cpg." in code:
os._exit(9)
# Honour the redirection wrapper by extracting its receipt and target.
if MODE == "noreceipt":
answer = json.dumps({"success": True, "stdout": "DIAGNOSTIC: not compiled"}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
return
if MODE in ("ok", "empty", "wrongreceipt") and "__ousastPath" in code:
target = code.split('java.nio.file.Path.of("', 1)[1].split('"', 1)[0]
nonce = code.split('__ousastPath, "', 1)[1].split(chr(92) + "n", 1)[0]
payload = "" if MODE == "empty" else "PAYLOAD-" + nonce
written = ("BAD" if MODE == "wrongreceipt" else nonce) + "\\n" + payload
with open(target, "w") as handle:
handle.write(written)
answer = json.dumps({"success": MODE != "unsuccessful", "stdout": ""}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
"""
)
@pytest.fixture
def fake(tmp_path):
script = tmp_path / "fake-joern"
script.write_text("#!" + sys.executable + "\n" + FAKE)
script.chmod(0o755)
return script
def session(tmp_path, fake, *, mode="ok", seconds=30.0, allowance=2.0):
return EngineSession(
tmp_path / "scratch",
ExecutionBudget(time.monotonic() + seconds, allowance),
{**os.environ, "FAKE_MODE": mode},
joern=str(fake),
startup_timeout_seconds=30.0,
)
def test_a_started_session_answers_only_with_its_own_receipt(tmp_path, fake):
with session(tmp_path, fake) as live:
assert live.start() and live.alive and live.startup_seconds > 0
answer = live.evaluate("cpg.file.size", timeout=10)
assert answer is not None and answer.stdout.startswith("PAYLOAD-")
assert live.failure == "" and live.requests == 1
# The private answer file is removed once verified, so no later call can reread it.
assert not list((tmp_path / "scratch").glob("answer-*.txt"))
def test_a_mismatched_receipt_poisons_the_session(tmp_path, fake):
with session(tmp_path, fake, mode="wrongreceipt") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.failure == "session_receipt_mismatch"
# Poisoned means poisoned: no later request may answer from an unknown state.
assert live.evaluate("cpg.method.size", timeout=10) is None and not live.alive
def test_an_unsuccessful_response_is_not_an_answer(tmp_path, fake):
with session(tmp_path, fake, mode="unsuccessful") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.failure == "session_request_unsuccessful"
def test_an_empty_body_is_an_answer_only_when_its_receipt_is_present(tmp_path, fake):
with session(tmp_path, fake, mode="empty") as live:
assert live.start()
answer = live.evaluate("cpg.file.size", timeout=10)
assert answer is not None and answer.stdout == ""
assert live.failure == ""
def test_a_crashed_session_answers_nothing_afterwards(tmp_path, fake):
with session(tmp_path, fake, mode="crash") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.evaluate("1", timeout=10) is None
assert live.failure
def test_cancellation_kills_the_process_group(tmp_path, fake):
live = session(tmp_path, fake, mode="hang", seconds=3.0)
assert live.start()
pid = live._process.pid
started = time.monotonic()
assert live.evaluate("ousastHang()", timeout=1.0) is None
live.close()
assert time.monotonic() - started < 6.0
with pytest.raises(ProcessLookupError):
for _ in range(100):
os.killpg(pid, 0)
time.sleep(0.02)
def test_an_exhausted_deadline_starts_nothing(tmp_path, fake):
live = session(tmp_path, fake, seconds=0.0)
assert live.start() is False and live.failure == "deadline_exhausted"
assert live._process is None
live.close()
def test_load_records_the_resident_graph_and_forgets_it_on_failure(tmp_path, fake):
with session(tmp_path, fake) as live:
assert live.start()
graph = tmp_path / "cpg.bin"
assert live.load(graph, timeout=10) and live.loaded_graph == str(graph)
with session(tmp_path, fake, mode="wrongreceipt") as broken:
assert broken.start()
assert broken.load(tmp_path / "other.bin", timeout=10) is False
assert broken.loaded_graph == ""
def test_the_wrapper_writes_its_receipt_before_running_the_body():
code = _redirected("cpg.file.size", Path("/tmp/x.txt"), "abc")
assert code.index("writeString") < code.index("scala.Console.withOut")
assert '"abc\\n"' in code and "/tmp/x.txt" in code
# The Joern REPL shadows `Console`, so an unqualified name compiles to the wrong object.
assert "scala.Console.withOut" in code
def test_a_launch_failure_is_recorded_not_raised(tmp_path):
live = EngineSession(tmp_path / "s", ExecutionBudget(time.monotonic() + 10, 1.0), dict(os.environ), joern=str(tmp_path / "absent"))
assert live.start() is False and live.failure.startswith("session_launch_failed")
live.close()
def test_signal_module_is_used_for_group_termination():
assert signal.SIGKILL
def test_a_definition_is_sent_unwrapped_and_is_not_proof_of_anything(tmp_path, fake):
"""M2 11.1: `@main` cannot be nested, and the server answers success for compile errors."""
with session(tmp_path, fake) as live:
assert live.start()
sent = live.define("@main def exec(cpgFile: String) = println(1)", timeout=10)
# The fake only writes a receipt file for wrapped requests, so an unwrapped one has none.
assert sent is not None and live.failure == ""
assert not list((tmp_path / "scratch").glob("answer-*.txt"))
def test_a_definition_on_a_dead_session_is_refused(tmp_path, fake):
with session(tmp_path, fake, mode="wrongreceipt") as live:
assert live.start()
assert live.evaluate("cpg.file.size", timeout=10) is None
assert live.define("def x = 1", timeout=10) is None
def test_the_engine_body_is_retained_so_a_missing_receipt_can_be_explained(tmp_path, fake):
"""M2 11.1: success with no receipt is a compile error, and the diagnostic is the only clue."""
with session(tmp_path, fake, mode="noreceipt") as live:
assert live.start()
assert live.evaluate("bogus(", timeout=10) is None
assert live.failure == "session_answer_missing"
assert live.last_body == "DIAGNOSTIC: not compiled"
def test_the_backend_leaves_the_session_off_by_default(tmp_path):
"""M2 11.3: shipped behaviour stays the disposable invocation that has been measured."""
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend()
assert backend.session_transport is False
assert backend._session_for(tmp_path / "cpg.bin") is None
assert backend._session_payload(tmp_path / "cpg.bin", "census", {"cpgFile": "x"}) is None
def test_a_session_that_cannot_start_falls_back_rather_than_answering(tmp_path):
from openultrasast.cpg.backend import JoernBackend
backend = JoernBackend(
session_transport=True,
execution_budget=ExecutionBudget(time.monotonic() + 10, 1.0),
)
backend.__dict__["_session"] = None
# No such engine: starting fails, the reason is recorded, and the caller gets None to fall back.
object.__setattr__(backend, "queries_dir", Path("/nonexistent"))
assert backend._session_payload(tmp_path / "cpg.bin", "census", {"cpgFile": "x"}) is None
assert any("session_unavailable" in note or "session" in note for note in backend._diagnostics) or backend._diagnostics == []
backend.close_session()