@clear-capabilities/agentic-security-scanner 0.188.0 → 0.190.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ // Project-reachability adjudication of Haskell taint findings.
2
+ //
3
+ // `haskell-ir.js` `projectReachability` stamps every function with `hs.reachability {status, rootKind, holeReason}`: could any caller exist, judged from
4
+ // the project's entry points over the call graph? This module reads that verdict for the function a taint finding was found in and records it on the
5
+ // finding as `reachability {status, reason, premise, roots, holeReason}`. It is a SEPARATE record from `trustBoundary`: that one answers who controls
6
+ // the input, this one answers whether a caller can exist, and either may be `unknown` independently.
7
+ //
8
+ // status entrypoint-reachable | unreachable-in-project | external-api | unknown
9
+ // premise what the finding's own claim rests on:
10
+ // caller-controlled-parameter the only origin recorded is the assumption that an untrusted caller supplies a parameter of
11
+ // an exported function (the weak label, `hs:caller-controlled`)
12
+ // recorded-read a real environment, command-line, file, standard-input, socket or request read is on the chain
13
+ //
14
+ // What the verdict does to the finding:
15
+ // unreachable-in-project and premise caller-controlled-parameter the premise is withdrawn (an entry-point-rooted walk over a hole-free graph says
16
+ // there is no caller at all): lowered to `info`, never dropped (`lowerFinding`, `_reachLowered`)
17
+ // unreachable-in-project and premise recorded-read NOT lowered. "Nothing calls this" is a claim about a call graph in a language where typeclass
18
+ // dispatch, re-exports and point-free aliasing routinely defeat call graphs; being wrong costs a real
19
+ // vulnerability, so it may cost rank (`posture/relevance.js` reads `f.reachability`) and not visibility
20
+ // unknown, external-api, entrypoint-reachable nothing happens to the severity
21
+ //
22
+ // Nothing here reads a comment, a name or a path: the inputs are the call-graph verdict and the origins the taint engine recorded.
23
+
24
+ import { lowerFinding, originClasses } from './haskell-trust.js';
25
+
26
+ const HS_FILE = /\.(?:l?hs|hsc)$/i;
27
+
28
+ const fnByQid = (perFile, qid) => {
29
+ if (!qid) return null;
30
+ const file = String(qid).split('::')[0];
31
+ const ir = perFile && perFile[file];
32
+ return ir ? ir.functions.find((x) => x.qid === qid) || null : null;
33
+ };
34
+
35
+ const REASONS = Object.freeze({
36
+ 'entrypoint-reachable': 'a call path from a project entry point reaches the function',
37
+ 'external-api': 'the function belongs to a module the package exposes, so its callers are outside the analysed code',
38
+ 'unreachable-in-project': 'no entry point of the project reaches the function through the call graph (complete scan, hole-free graph, a main and a manifest present, module not exposed)',
39
+ });
40
+ const HOLE_TEXT = Object.freeze({
41
+ 'partial-scan': 'the scan covered only part of the project',
42
+ 'policy-excluded': 'Haskell sources were kept out of the scan by the path policy',
43
+ 'size-skipped': 'Haskell sources were too large to read',
44
+ 'no-entrypoint': 'the analysed files contain no `main`, so there is no entry point to walk from',
45
+ 'no-manifest': 'no manifest says which modules the package exposes',
46
+ 'parse-failure': 'a declaration could not be read, so its calls are missing from the graph',
47
+ 'function-cap': 'functions past the per-file cap are not in the graph',
48
+ 'opaque-boundary': 'Template Haskell, a quasi-quote, a foreign export, a build step that generates code or a source format the IR does not read can call functions the graph does not show',
49
+ 'unresolved-call': 'a function reachable from an entry point makes a call the IR could not resolve',
50
+ 'instance-method': 'an instance method can be invoked by anything that can see its type',
51
+ 'unresolved-route': 'a route registration could not be resolved',
52
+ });
53
+
54
+ /** What the finding's own claim rests on (see the header). `null` when the recorded origins do not settle it. */
55
+ function premiseOf(f) {
56
+ const { roots, classes } = originClasses(f);
57
+ if (!roots.length) return null;
58
+ if ([...classes].every((c) => c === 'weak-param')) return 'caller-controlled-parameter';
59
+ if ([...classes].some((c) => c === 'operator' || c === 'external' || c === 'data')) return 'recorded-read';
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Stamps `reachability` on every Haskell IR-TAINT finding that has a verdict for its function, and lowers to `info` the ones whose only premise
65
+ * (a caller-controlled parameter) the verdict withdraws. Mutates the findings; returns counts.
66
+ */
67
+ export function applyHaskellReachability(findings, perFile) {
68
+ const stats = { 'entrypoint-reachable': 0, 'unreachable-in-project': 0, 'external-api': 0, unknown: 0, lowered: 0, rankOnly: 0, unstamped: 0 };
69
+ for (const f of findings || []) {
70
+ if (!f || f.parser !== 'IR-TAINT' || !HS_FILE.test(String(f.file || ''))) continue;
71
+ const fn = fnByQid(perFile, f._funcQid);
72
+ const r = fn && fn.hs && fn.hs.reachability;
73
+ if (!r || !r.status) { stats.unstamped++; continue; }
74
+ const premise = premiseOf(f);
75
+ const reason = r.status === 'unknown' ? (HOLE_TEXT[r.holeReason] || 'the call graph cannot settle it') : REASONS[r.status];
76
+ f.reachability = { status: r.status, reason, premise, roots: r.rootKind ? [r.rootKind] : [], holeReason: r.holeReason || null };
77
+ stats[r.status] = (stats[r.status] || 0) + 1;
78
+ if (r.status !== 'unreachable-in-project') continue;
79
+ if (premise === 'caller-controlled-parameter') {
80
+ if (lowerFinding(f, { kind: 'unreachable-in-project', reason: 'the finding rests on a caller of an exported function supplying a parameter, and nothing in the project can call it', controlKind: 'no-caller-in-project', guard: '_reachLowered', keepNote: true })) stats.lowered++;
81
+ } else if (premise === 'recorded-read') stats.rankOnly++;
82
+ }
83
+ return stats;
84
+ }
@@ -162,6 +162,26 @@ function snippetOf(text, line) {
162
162
  // socket or connection handle is filled by the peer. A read of standard input is the operator's in a program's own control flow, and
163
163
  // the caller's only in an entry point nothing in the analysed code calls (a library function handing back whatever its caller pipes in).
164
164
  const NETWORK_HANDLE_CALL = /(?:^|\.)(?:socketToHandle|connectTo|accept|acceptOn|openTCPConnection)$/;
165
+
166
+ /**
167
+ * Are this function's callers outside the analysed code? With no project-reachability verdict, or an `unknown` one, the answer is exactly what it
168
+ * has always been (nothing in the analysed code calls or references it). A function no caller can reach (`unreachable-in-project`) has none. One
169
+ * reached from an entry point takes its parameters from that path, EXCEPT a root nothing in the analysed code calls (a framework handler, a foreign
170
+ * export, code the parser withheld): its caller is outside.
171
+ *
172
+ * A function of an exposed module (`external-api`) is DELIBERATELY answered as today: the plan for this change said "a public API's callers are
173
+ * external, so true", but for an exposed function that something in the project also calls that turns on the partial-function and unbounded-read
174
+ * rules for every such function, and on the development set it added seven flagged sites and no true positive (precision 35.7% -> 23.8%). The
175
+ * status is still recorded and still keeps the taint finding at full severity; it just does not widen these two rules. Flipping it is the one line
176
+ * below, with that measurement as the cost.
177
+ */
178
+ export function callersUnknown(fn) {
179
+ const hs = (fn && fn.hs) || {};
180
+ const r = hs.reachability || null;
181
+ if (!r || r.status === 'unknown' || r.status === 'external-api') return !hs.reached;
182
+ if (r.status === 'entrypoint-reachable') return !hs.reached && r.rootKind !== 'main';
183
+ return false;
184
+ }
165
185
  function readsOutsideInput(fn, callee, args, importsNetwork) {
166
186
  const h = args && args[0];
167
187
  const isStdin = !!h && h.kind === 'ident' && /(?:^|\.)stdin$/.test(h.name || '');
@@ -169,7 +189,7 @@ function readsOutsideInput(fn, callee, args, importsNetwork) {
169
189
  // the networking packages are not all in the model registry, so a call keeps its bare name; the module imports say which library it is
170
190
  if (handleRead) return !!importsNetwork && (fn.calls || []).some((x) => typeof x.callee === 'string' && NETWORK_HANDLE_CALL.test(x.callee));
171
191
  const hs = fn.hs || {};
172
- return !hs.reached && !/(?:^|\.)main$/.test(fn.name || '');
192
+ return callersUnknown(fn) && !/(?:^|\.)main$/.test(fn.name || '');
173
193
  }
174
194
 
175
195
  const EQUALITY_OPS = new Set(['==', '/=', '!=']);
@@ -244,7 +264,7 @@ export function analyzeHaskellRules(files, opts = {}) {
244
264
  const notes = [];
245
265
  if (!Object.keys(hs).length) return { findings, controls, notes };
246
266
 
247
- const ir = buildHaskellIR(hs);
267
+ const ir = buildHaskellIR(hs, opts.reachability || {});
248
268
 
249
269
  // project-level facts
250
270
  let requestLimit = null;
@@ -291,7 +311,7 @@ export function analyzeHaskellRules(files, opts = {}) {
291
311
  // calls cannot say the text comes from outside.
292
312
  const fnHs = fn.hs || {};
293
313
  const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
294
- const entryLike = !fnHs.reached;
314
+ const entryLike = callersUnknown(fn);
295
315
  const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
296
316
  if (derivedText.size) {
297
317
  for (let round = 0; round < 8; round++) {
@@ -840,7 +840,8 @@ export function parseSyntax(src, opts = {}) {
840
840
  // `foreign import ccall unsafe "string.h strlen" c_strlen :: ...` names the Haskell-side function.
841
841
  let fname = null;
842
842
  for (let q = a; q < b; q++) if (isRop(T[q], '::')) { if (q > a && T[q - 1].t === 'var') fname = T[q - 1].v; break; }
843
- res.boundaries.push({ kind: 'ffi', line: t0.line, detail: 'foreign declaration; the foreign code is not analyzed', ...(fname ? { name: fname } : {}) });
843
+ const dir = T[a + 1] && (T[a + 1].v === 'import' || T[a + 1].v === 'export') ? T[a + 1].v : null;
844
+ res.boundaries.push({ kind: 'ffi', line: t0.line, detail: 'foreign declaration; the foreign code is not analyzed', ...(dir ? { direction: dir } : {}), ...(fname ? { name: fname } : {}) });
844
845
  continue;
845
846
  }
846
847
  if (t0.v === 'class' || t0.v === 'instance') {
@@ -217,6 +217,28 @@ function findCallsTo(fn, target) {
217
217
  return out;
218
218
  }
219
219
 
220
+ /**
221
+ * Lower a finding to `info` and say why, without dropping it: the severity it would have had is kept (`originalSeverity`), a control records the
222
+ * reason (`controls`), and `guardNote` is set (with `keepNote`, only when no earlier pass set one). Idempotent per `guard` (a finding is lowered once by each reason), and two
223
+ * reasons compose: the second finds the severity already `info`, keeps the original severity the first recorded, and adds its own control.
224
+ * Used by the trust-boundary pass (`_trustLowered`) and the reachability pass (`_reachLowered`).
225
+ */
226
+ export function lowerFinding(f, { kind, reason, controlKind, guard, keepNote = false }) {
227
+ if (!f || !guard || f[guard]) return false;
228
+ f[guard] = true;
229
+ f.originalSeverity = f.originalSeverity || f.severity;
230
+ f.severity = 'info';
231
+ f.controls = [...(f.controls || []), { kind: controlKind, evidenceKind: 'source' }];
232
+ if (!keepNote || !f.guardNote) f.guardNote = { kind, reason };
233
+ return true;
234
+ }
235
+
236
+ /** The classes of origin a finding's recorded roots fall in (`operator`, `external`, `data`, `weak-param`, `other`), and the roots themselves. */
237
+ export function originClasses(f) {
238
+ const roots = rootsOf(f);
239
+ return { roots, classes: new Set(roots.map(classOf)) };
240
+ }
241
+
220
242
  /**
221
243
  * Stamps `trustBoundary` on every Haskell IR-TAINT finding and lowers to `info` the verdicts that name no boundary crossed
222
244
  * (`operator-controlled`, `api-contract`). Mutates the findings; returns counts.
@@ -256,12 +278,8 @@ export function applyTrustBoundary(findings, perFile) {
256
278
  f.trustBoundary = { verdict, reason, role, ...(inferred ? { inferred: true } : {}), roots: roots.slice(0, 5) };
257
279
  const key = verdict.replace(/-(\w)/g, (_, c) => c.toUpperCase());
258
280
  stats[key] = (stats[key] || 0) + 1;
259
- if ((verdict === 'operator-controlled' || verdict === 'api-contract') && !f._trustLowered) {
260
- f._trustLowered = true;
261
- f.originalSeverity = f.originalSeverity || f.severity;
262
- f.severity = 'info';
263
- f.controls = [...(f.controls || []), { kind: verdict === 'operator-controlled' ? 'operator-controlled-input' : 'api-contract-wrapper', evidenceKind: 'source' }];
264
- f.guardNote = { kind: verdict, reason };
281
+ if (verdict === 'operator-controlled' || verdict === 'api-contract') {
282
+ lowerFinding(f, { kind: verdict, reason, controlKind: verdict === 'operator-controlled' ? 'operator-controlled-input' : 'api-contract-wrapper', guard: '_trustLowered' });
265
283
  }
266
284
  }
267
285
  return stats;
@@ -83,9 +83,9 @@ const strLit = (e) => { e = unparen(e); return e && e.t === 'lit' && e.kind ===
83
83
 
84
84
  // ── analysis context ─────────────────────────────────────────────────────────
85
85
  class Ctx {
86
- constructor(files) {
86
+ constructor(files, reachability) {
87
87
  this.files = files;
88
- this.ir = buildHaskellIR(files);
88
+ this.ir = buildHaskellIR(files, reachability || {});
89
89
  this.project = this.ir.project;
90
90
  this.modules = [...this.ir.modules.values()];
91
91
  this.fnByQid = new Map();
@@ -837,7 +837,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
837
837
  for (const [f, t] of Object.entries(files || {})) if (/\.hs$/i.test(f) && typeof t === 'string') hs[f] = t;
838
838
  const result = { routes: [], findings: [], middleware: [], frameworks: [], coverage: {}, gaps: [], modelVersion: HS_WEB_MODEL_VERSION, rulesetVersion: HS_WEB_RULES_VERSION, versionStatus: {} };
839
839
  if (!Object.keys(hs).length) return result;
840
- const ctx = new Ctx(hs);
840
+ const ctx = new Ctx(hs, opts.reachability);
841
841
  const pending = [];
842
842
  const add = (r) => pending.push(r);
843
843
 
@@ -26,6 +26,7 @@ import path from 'node:path';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { runConfined, sandboxAvailable } from '../sandbox/index.js';
28
28
  import { analyzeNixScripts, SHELL_PLACEHOLDER } from './nix-script-taint.js';
29
+ import { bootModuleSet } from '../posture/proof-fabric/generate-haskell.js';
29
30
 
30
31
  export const WITNESS_VERSION = 'language-witness/1';
31
32
  export const PROOF_MARKER = 'PROVEN';
@@ -185,3 +186,98 @@ export function nixFixWitness(finding, opts = {}) {
185
186
  return judgeFixWitness(witnessNixFlow(nixOnly(files), target, opts), witnessNixFlow(nixOnly(patched), target, opts));
186
187
  };
187
188
  }
189
+
190
+ // ── Haskell: the fix witness ──────────────────────────────────────────────────
191
+ // `haskellFixWitness(finding)` is the `witness` hook of `runFixLifecycle` for a Haskell fix, the counterpart of `nixFixWitness`. It runs the ORIGINAL
192
+ // and the PATCHED file under GHC's interpreter inside the confinement sandbox (`ghc -e ':load *File' -e 'fn "<payload>"'`, every top-level name in
193
+ // scope, so the function need not be exported) and judges them with `judgeFixWitness`: verified only when the original reproduced the effect and the
194
+ // patched code ran without reproducing it.
195
+ //
196
+ // Where it can honestly be built: a command-injection fix (`behavior.kind === 'process'`) in a top-level function whose parameters are all
197
+ // String/FilePath and whose result is IO, in a file whose imports are all modules of the packages that ship with GHC (`bootModuleSet`, the same
198
+ // decision the proof fabric makes before it runs anything). The payload makes the shell create `PROVEN`; the marker is the only thing that proves.
199
+ // Every other class is REFUSED with the reason (never faked): the effect is not observable by a program GHC can run alone (a TLS peer, a response
200
+ // header of a web framework, a database), not an exploit at all (a digest, a log line), or a property of a distribution (randomness).
201
+ const WITNESS_REFUSALS = Object.freeze({
202
+ tls: 'certificate validation is observable only against a TLS server presenting an untrusted certificate, and the confinement environment denies the network',
203
+ cookie: 'a cookie attribute is a property of a response header produced by a web framework (wai, scotty, servant) that GHC does not ship, so no program can run it here',
204
+ random: 'unpredictability is a property of a distribution; no single run reproduces or refutes it',
205
+ 'body-limit': 'the effect needs a wai Request with a streamed body, and wai is not a package GHC ships',
206
+ sql: 'the effect is observable only in a database driver, which GHC does not ship',
207
+ html: 'the effect is observable only in the rendering library, which GHC does not ship',
208
+ hash: 'a weaker digest is not an exploitable effect a program can demonstrate',
209
+ log: 'the effect is a value appearing in a log, which depends on the logging setup the file does not state',
210
+ path: 'the base directory is the program\'s own and is not present in the sandbox, so a traversal out of it cannot be planted and observed',
211
+ });
212
+
213
+ /** The top-level function around `line`: {name, params:[{name,type}], result} from its signature and first clause, or null. */
214
+ export function haskellFunctionAt(text, line) {
215
+ const lines = String(text).split('\n');
216
+ let clause = -1;
217
+ for (let i = Math.min(line - 1, lines.length - 1); i >= 0; i--) if (/^[a-z_][\w']*\b[^:\n]*=/.test(lines[i]) && !/^[a-z_][\w']*\s*::/.test(lines[i])) { clause = i; break; }
218
+ if (clause < 0) return null;
219
+ const m = /^([a-z_][\w']*)([^=]*)=/.exec(lines[clause]);
220
+ if (!m) return null;
221
+ const params = m[2].trim() ? m[2].trim().split(/\s+/) : [];
222
+ if (params.some((p) => !/^[a-z_][\w']*$/.test(p))) return null;
223
+ const sigLine = lines.findIndex((l) => new RegExp(`^${m[1]}\\s*::`).test(l));
224
+ if (sigLine < 0) return null;
225
+ let sig = lines[sigLine].replace(/^[^:]*::/, '');
226
+ for (let j = sigLine + 1; j < lines.length && /^\s+\S/.test(lines[j]) && !/^[a-z_][\w']*\s*(::|=)/.test(lines[j]); j++) sig += ` ${lines[j].trim()}`;
227
+ if (/=>/.test(sig)) return null; // a constrained signature: the argument types are not concrete
228
+ const types = []; let depth = 0; let cur = '';
229
+ for (let i = 0; i < sig.length; i++) {
230
+ const c = sig[i];
231
+ if (c === '(' || c === '[') depth++; else if (c === ')' || c === ']') depth--;
232
+ if (depth === 0 && sig.startsWith('->', i)) { types.push(cur.trim()); cur = ''; i++; continue; }
233
+ cur += c;
234
+ }
235
+ types.push(cur.trim());
236
+ if (types.length !== params.length + 1) return null;
237
+ return { name: m[1], params: params.map((p, i) => ({ name: p, type: types[i] })), result: types[types.length - 1] };
238
+ }
239
+
240
+ /** Runs `expr` in the interpreter with `file` loaded (all its top-level names in scope), confined. Never throws. */
241
+ export function runHaskellEval(tree, file, expr, { timeoutMs = 60_000, force } = {}) {
242
+ const base = { witness: WITNESS_VERSION, lang: 'haskell', ran: false, backend: null, marker: PROOF_MARKER };
243
+ const ghc = which('ghc');
244
+ if (!ghc) return { ...base, status: 'unavailable', reason: 'no GHC on PATH; the Haskell witness cannot run here' };
245
+ if (!sandboxAvailable()) return { ...base, status: 'invalid', reason: 'no confinement primitive available; refusing to execute' };
246
+ let root;
247
+ try {
248
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'witness-hs-')));
249
+ for (const [f, t] of Object.entries(tree)) if (/\.l?hs$/i.test(f) && typeof t === 'string') { fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true }); fs.writeFileSync(path.join(root, f), t); }
250
+ const r = runConfined([ghc, '-isrc', '-i.', '-e', `:load *${file}`, '-e', expr], { root, timeoutMs, force });
251
+ if (r.status === 'disabled' || r.status === 'error') return { ...base, backend: r.backend, status: 'invalid', reason: `the sandbox could not execute it: ${String(r.stderr || '').trim() || r.status}` };
252
+ if (r.timedOut) return { ...base, backend: r.backend, status: 'invalid', reason: 'the witness exceeded its time budget', timedOut: true };
253
+ const proven = fs.existsSync(path.join(root, PROOF_MARKER));
254
+ if (!proven && /\[GHC-\d+\]|Could not find module|parse error|error:\s/.test(String(r.stderr || ''))) return { ...base, backend: r.backend, status: 'invalid', reason: `the module did not load or run: ${String(r.stderr).trim().split('\n').find((l) => l.trim()) || 'GHC rejected it'}` };
255
+ return { ...base, ran: true, backend: r.backend, status: proven ? 'reproduced' : 'not-reproduced', exitCode: r.exitCode, reason: proven ? null : 'the function ran and the marker was not created' };
256
+ } catch (e) {
257
+ return { ...base, status: 'invalid', reason: `witness harness error: ${String((e && e.message) || e)}` };
258
+ } finally { if (root) fs.rmSync(root, { recursive: true, force: true }); }
259
+ }
260
+
261
+ const importsOf = (text) => [...String(text).matchAll(/^import\s+(?:safe\s+)?(?:qualified\s+)?([A-Z][\w.']*)/gm)].map((m) => m[1]);
262
+
263
+ /** A `witness` hook for runFixLifecycle over a Haskell fix. See the block comment above for what is and is not supported. */
264
+ export function haskellFixWitness(finding, opts = {}) {
265
+ return (plan, files, patched) => {
266
+ const kind = plan && plan.behavior && plan.behavior.kind;
267
+ const no = (reason) => ({ ok: false, verified: false, reason });
268
+ if (kind !== 'process') return no(`no controlled witness can be built for ${kind || 'this'} fixes: ${WITNESS_REFUSALS[kind] || 'the class has no observable effect a program GHC runs alone could produce'}`);
269
+ const fn = haskellFunctionAt(plan.before, plan.fixedAtLine || finding.line);
270
+ if (!fn || !fn.params.length || !/^IO\b/.test(fn.result) || fn.params.some((p) => !/^(?:String|FilePath)$/.test(p.type))) return no('the flagged call is not in a top-level function of String/FilePath parameters returning IO, so there is no argument to put a payload in');
271
+ const ghc = opts.ghc || which('ghc');
272
+ if (!ghc) return { ok: false, verified: false, status: 'unavailable', reason: 'no GHC on PATH; the Haskell witness is unavailable here' };
273
+ const boot = bootModuleSet(ghc);
274
+ if (!boot) return no('the modules the installed GHC provides could not be listed, so whether the file can be loaded is unknown');
275
+ const external = [...new Set([...importsOf(plan.before), ...importsOf(plan.after)])].filter((m) => !boot.has(m));
276
+ if (external.length) return no(`the file imports ${external.slice(0, 4).join(', ')}, which GHC's boot packages do not provide, so it cannot be loaded for a witness`);
277
+ if (/\{-#\s*LANGUAGE[^#]*\b(?:TemplateHaskell|QuasiQuotes|CPP)\b/.test(plan.before)) return no('the file uses Template Haskell, quasi-quotes or CPP, which run code at load time');
278
+ const payload = `x; touch ${PROOF_MARKER}`;
279
+ const expr = `${fn.name} ${fn.params.map(() => JSON.stringify(payload)).join(' ')}`;
280
+ const only = (t) => Object.fromEntries(Object.entries(t).filter(([p, x]) => p === plan.file && typeof x === 'string'));
281
+ return judgeFixWitness(runHaskellEval(only(files), plan.file, expr, opts), runHaskellEval(only(patched), plan.file, expr, opts));
282
+ };
283
+ }
package/src/mcp/tools.js CHANGED
@@ -1219,7 +1219,7 @@ export const synthesize_fix = {
1219
1219
  if (lc.languageOfFinding(f)) {
1220
1220
  const proj = lc.loadLanguageProject(ctx.sessionRoot);
1221
1221
  const prev = await lc.languageFixPreview(f, proj.files);
1222
- languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
1222
+ languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, decision: prev.decision || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
1223
1223
  // A fix that spans several files is applied with apply_fix + plan_digest (atomic, one history group); see _applyMultiFilePlan.
1224
1224
  if (prev.ok && prev.edits) {
1225
1225
  languageFix.multiFile = prev.edits.map((e) => e.file);
@@ -11,8 +11,19 @@
11
11
  // The container is named, and removed (`rm -f`) on every exit path including timeout and cancellation, because killing the `docker`
12
12
  // client does not by itself stop the container.
13
13
  //
14
- // HONEST LIMIT: this has been exercised against a fake engine that records the arguments and runs the command locally, which proves
15
- // the plumbing and the flags. It has not been run against a real Docker or Podman daemon in this repository's test environment.
14
+ // WHAT HAS AND HAS NOT BEEN RUN. The arguments are pinned flag by flag against a fake engine that records them. Against a REAL engine it has
15
+ // run: bench/proof-fabric/RESULT-linux-container.json records 72 container runs (36 vulnerable reproducers confirmed, 0 false confirmations, the
16
+ // timeouts degraded as designed) on Docker 28.0.4 on a Linux x64 runner, over the gcc, eclipse-temurin:21, node:24-slim and python:3-slim images
17
+ // (the Temurin run is what found the `javac: not found` PATH problem above), and the `proof-container` CI job runs it on every change. The
18
+ // residual limits, none of them closed by that run:
19
+ // * ONE engine and ONE version on ONE host class. Other Docker versions, rootless Docker and other kernels are unmeasured.
20
+ // * Podman is not probed: it is a supported engine name, and no run against it exists.
21
+ // * macOS is unmeasured. Docker Desktop runs containers in a VM, so bind mounts, uids and file ownership behave differently from the Linux
22
+ // measurement; no macOS container run has been recorded.
23
+ // * The haskell:9 image is wired (CONTAINER_TOOLS, CONTAINER_PATH) but its toolchain layout for a non-root user was never run against a real
24
+ // engine; the measurement skips Haskell unless AGENTIC_SECURITY_PROOF_CONTAINER_HASKELL=1 (bench/proof-fabric/measure.mjs). The result file
25
+ // names no .NET image either, so the dotnet SDK image is unverified in the same way.
26
+ // * A reproducer that needs the loopback callback listener (SSRF) cannot run with `--network none` and is skipped, not measured.
16
27
  import { spawnSync } from 'node:child_process';
17
28
  import { superviseSpawn } from '../../sandbox/supervise.js';
18
29
  import { scrubEnv } from '../../sandbox/trust-domains.js';
@@ -262,6 +262,21 @@ function _threatBonus(f, threatModel, factors) {
262
262
  return bonus;
263
263
  }
264
264
 
265
+ // A language that reasons about its own call graph (Haskell: `language/haskell-reachability.js`) states `f.reachability = {status, reason, premise,
266
+ // roots, holeReason}`. That verdict is a statement about the FUNCTION, from the project's entry points, so it outranks this module's file-level
267
+ // import graph, which has no Haskell extraction. Only a POSITIVE verdict moves the tier; `unknown` (and any finding without one) falls through
268
+ // to the file-level logic unchanged. It re-ranks only: severity is never touched here (a lowering, where one is justified, is the language
269
+ // pass's own decision and is recorded on the finding).
270
+ const LANGUAGE_STATUSES = new Set(['entrypoint-reachable', 'unreachable-in-project', 'external-api', 'unknown']);
271
+ function languageVerdict(f) {
272
+ const r = f && f.reachability;
273
+ if (!r || typeof r !== 'object' || !LANGUAGE_STATUSES.has(r.status) || !Object.hasOwn(r, 'holeReason')) return null;
274
+ if (r.status === 'unreachable-in-project') return { tier: 'unreachable', reachable: false, factor: `no call path from any entry point of the project (${r.reason || 'call graph complete'})` };
275
+ if (r.status === 'entrypoint-reachable') return { tier: 'indirect', reachable: true, factor: 'reachable from a project entry point through the call graph' };
276
+ if (r.status === 'external-api') return { tier: 'indirect', reachable: true, factor: 'part of the package\'s exposed API: its callers are outside the analysed code' };
277
+ return null;
278
+ }
279
+
265
280
  /**
266
281
  * Pure scorer. Does NOT mutate the finding.
267
282
  *
@@ -280,7 +295,12 @@ function scoreRelevance(f, graph, threatModel) {
280
295
 
281
296
  let tier;
282
297
  let reachable;
283
- if (!file || entryFiles.size === 0) {
298
+ const lang = languageVerdict(f);
299
+ if (lang && !(lang.tier === 'indirect' && file && entryFiles.has(file))) {
300
+ tier = lang.tier;
301
+ reachable = lang.reachable;
302
+ factors.push(lang.factor);
303
+ } else if (!file || entryFiles.size === 0) {
284
304
  tier = 'unknown';
285
305
  reachable = null;
286
306
  factors.push(entryFiles.size === 0