@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -18,6 +18,7 @@ import * as ids from './ids.js';
18
18
  import { classifyDataElementName } from './classification.js';
19
19
  import { analyzeNixSecrets } from '../language/nix-secrets.js';
20
20
  import { analyzeNixPersonalData, destinationOf } from '../language/nix-privacy.js';
21
+ import { analyzeNixScripts } from '../language/nix-script-taint.js';
21
22
 
22
23
  export const NIX_VIEW_VERSION = 'nix-lineage/1';
23
24
 
@@ -196,3 +197,37 @@ export function contributeNixConfigLineage(graph, files, opts = {}) {
196
197
  }
197
198
  return result;
198
199
  }
200
+
201
+ // ── DAG-08: configuration and generated-script scope carried through links ─────────────────────────────────────────────────
202
+ // A Nix value that reaches a generated script is STATIC CONFIGURATION evidence. It carries both the original Nix location and the
203
+ // generated-script location, and it stays that way when a cross-repo link or a boundary contract refers to it. It does not become
204
+ // an application-runtime value by name, by shared attribute or by being declared: only an explicit binding that a verifier has
205
+ // checked (a named verifier and an application site) lets a record say the value enters application runtime.
206
+ export const NIX_SCOPE = Object.freeze({ language: 'nix', kind: 'configuration', scope: 'static-configuration', runtime: false });
207
+
208
+ /** Script-level Nix findings as scope records that keep the original and the generated location side by side. */
209
+ export function nixGeneratedScriptScopes(files) {
210
+ const r = analyzeNixScripts({ files });
211
+ return (r.findings || []).filter((f) => f.originalLocation).map((f) => ({
212
+ rule: f.rule, attrPath: f.attrPath || null, file: f.file,
213
+ originalLocation: { file: f.file, line: f.originalLocation.startLine, column: f.originalLocation.startColumn },
214
+ generatedLocation: f.generatedLocation ? { generated: true, line: f.generatedLocation.line, column: f.generatedLocation.startColumn } : null,
215
+ origins: (f.origins || []).map((o) => ({ kind: o.kind, detail: o.detail })),
216
+ scope: NIX_SCOPE, application: { entersRuntime: false, binding: null, observed: false },
217
+ }));
218
+ }
219
+
220
+ /**
221
+ * Decide whether a Nix scope record may claim to enter application runtime. It may only with an explicit binding that names the
222
+ * application site (file, function) and a verifier (`verified: true` plus `verifiedBy`). Anything less keeps the static-configuration
223
+ * scope and says why. A matching name is never a binding, and even a verified binding is a declaration, never an observation.
224
+ */
225
+ export function bindNixValueToApplication(record, binding) {
226
+ const base = { ...record, scope: NIX_SCOPE };
227
+ const ok = binding && binding.verified === true && typeof binding.verifiedBy === 'string' && binding.verifiedBy.length > 0
228
+ && binding.site && typeof binding.site.file === 'string' && typeof binding.site.function === 'string';
229
+ if (!ok) {
230
+ return { ...base, application: { entersRuntime: false, binding: null, observed: false, reason: binding ? 'binding is not explicit and verified (needs verified: true, verifiedBy and an application site)' : 'no binding declared; the value is static configuration only' } };
231
+ }
232
+ return { ...base, application: { entersRuntime: true, binding: { site: { file: binding.site.file, function: binding.site.function }, verifiedBy: binding.verifiedBy }, observed: false, reason: 'explicit verified binding; this is a declaration, not an observed runtime flow' } };
233
+ }
@@ -432,9 +432,10 @@ export function parseOtelJsonObservations(text, context) {
432
432
  * `destination.service`), `protocol` (normalized — Envoy writes
433
433
  * `"HTTP/1.1"`, which contains `/` and is not an approved identifier
434
434
  * shape; lowercased and `/` stripped, e.g. `"http1.1"`), and
435
- * `response_flags` (`'-'`/empty means no flag, i.e. allowed; a non-empty
436
- * flags string, e.g. `"UAEX"` for unauthorized, is treated as `'denied'`
437
- * via `network.securityDecision`). `start_time`/`end_time` (or
435
+ * `response_flags` (`'-'`/empty means no flag, i.e. allowed; `"UAEX"`, the
436
+ * external authorization service denying the request, is `'denied'` via
437
+ * `network.securityDecision`; any other flag, such as an upstream failure or
438
+ * a missing route, is an operational outcome and carries no decision). `start_time`/`end_time` (or
438
439
  * `start_time` alone, with `duration` in milliseconds added) feed
439
440
  * `firstObservedAt`/`lastObservedAt`. Everything else — client address,
440
441
  * request path/query, user agent, request id, response code's own exact
@@ -477,7 +478,13 @@ export function parseEnvoyAccessLogObservations(text, context) {
477
478
  if (cluster) attributes['destination.service'] = cluster;
478
479
  if (protocol) attributes['network.protocol'] = protocol;
479
480
  if (typeof record.response_flags === 'string') {
480
- attributes['network.securityDecision'] = record.response_flags.trim().length === 0 || record.response_flags.trim() === '-' ? 'allowed' : 'denied';
481
+ // Only a flag that records an authorization decision is a security decision. UAEX is the external authorization service
482
+ // denying the request. Every other flag (UF upstream connection failure, NR no route, UT timeout, DC client disconnect) is
483
+ // an operational outcome, and calling it "denied" would invent a denial, so those make no claim either way.
484
+ const flags = record.response_flags.trim();
485
+ const tokens = flags.split(',').map((t) => t.trim()).filter(Boolean);
486
+ if (flags.length === 0 || flags === '-') attributes['network.securityDecision'] = 'allowed';
487
+ else if (tokens.includes('UAEX')) attributes['network.securityDecision'] = 'denied';
481
488
  }
482
489
 
483
490
  if (Object.keys(attributes).length === 0) continue; // nothing safe/present to report for this line
@@ -509,10 +516,8 @@ export function parseEnvoyAccessLogObservations(text, context) {
509
516
  * `destination.service` — never `objectRef.name`, which is frequently a
510
517
  * user-chosen, potentially sensitive resource name) and
511
518
  * `responseStatus.code` (mapped to `network.securityDecision`: `< 400`
512
- * is `'allowed'`, `>= 400` is `'denied'` — a client error is treated as a
513
- * decision here because the audit log's own admission/authorization
514
- * failures surface as 401/403/409, which is exactly the "security
515
- * decision" fact this adapter exists to carry). `requestReceivedTimestamp`/
519
+ * is `'allowed'`, `401`/`403` is `'denied'`, anything else carries no
520
+ * decision: a not-found or a conflict is not an authorization outcome). `requestReceivedTimestamp`/
516
521
  * `stageTimestamp` feed the observed-at window. **`user.username` and
517
522
  * every request/response body field are deliberately never read** — a
518
523
  * K8s audit event's `user` block is exactly the "authenticated identity"
@@ -554,7 +559,10 @@ export function parseK8sAuditLogObservations(text, context) {
554
559
  if (namespace && resource) attributes['destination.service'] = `${namespace}:${resource}`;
555
560
  else if (resource) attributes['destination.service'] = resource;
556
561
  if (typeof code === 'number' && Number.isFinite(code)) {
557
- attributes['network.securityDecision'] = code < 400 ? 'allowed' : 'denied';
562
+ // 401 and 403 are the authentication and authorization outcomes. Any other client or server error (404 not found, 409 conflict,
563
+ // 422 invalid, 5xx) is an operational outcome and makes no claim, the same rule as the proxy adapters.
564
+ if (code < 400) attributes['network.securityDecision'] = 'allowed';
565
+ else if (code === 401 || code === 403) attributes['network.securityDecision'] = 'denied';
558
566
  }
559
567
 
560
568
  if (Object.keys(attributes).length === 0) continue;
@@ -0,0 +1,77 @@
1
+ // owned-build.js: run the lineage graph build under an owned worker with a
2
+ // deadline and a watchdog (DAG-07 R07.7).
3
+ //
4
+ // The in-process build (index.js buildLineageGraph) is unchanged and remains
5
+ // the default. This module is the opt-in path: it sends the file contents to a
6
+ // worker thread this module owns, which rebuilds the IR and call graph and runs
7
+ // the same buildLineageGraph with a cooperative stop signal. Two outcomes:
8
+ // - the field analysis polls the stop flag, finishes the function it is in and
9
+ // returns a graph whose scanHealth.status is 'cancelled' (cooperative);
10
+ // - a worker that never polls (or is stuck in a phase that cannot be stopped,
11
+ // such as parsing) is terminated by the watchdog `ackGraceMs` after the
12
+ // deadline, and the result is {status:'cancelled', terminatedByWatchdog}.
13
+ // Either way the caller is released and the worker thread is gone
14
+ // (`exited` resolves when it is).
15
+ //
16
+ // Enabled by opts.deadlineMs or AGENTIC_SECURITY_LINEAGE_DEADLINE_MS. Not set
17
+ // means not used, and nothing about the default path changes.
18
+
19
+ import { fileURLToPath } from 'node:url';
20
+ import { runOwnedWorker } from './owned-worker.js';
21
+ import { buildLineageGraph } from './index.js';
22
+ import { buildProjectIR, buildProjectIRAsync } from '../ir/index.js';
23
+
24
+ const SELF = fileURLToPath(import.meta.url);
25
+
26
+ /** Resolve the configured deadline in ms, or null when the owned path is off. */
27
+ export function resolveLineageDeadlineMs(opts = {}, env = process.env) {
28
+ const raw = opts.deadlineMs !== undefined ? opts.deadlineMs : env.AGENTIC_SECURITY_LINEAGE_DEADLINE_MS;
29
+ const n = Number(raw);
30
+ return Number.isFinite(n) && n > 0 ? n : null;
31
+ }
32
+
33
+ /**
34
+ * Worker body. Called as fn(spec, { shouldStop }) by owned-worker-entry.js.
35
+ * `spec.opts` must be structured-cloneable (no functions).
36
+ */
37
+ export async function lineageWorkerBody(spec, ctl) {
38
+ const fc = spec.fileContents || {};
39
+ const hasJava = Object.keys(fc).some((f) => /\.java$/i.test(f));
40
+ const { perFile, callGraph } = hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc);
41
+ const signal = { get aborted() { return !!(ctl && ctl.shouldStop && ctl.shouldStop()); } };
42
+ return buildLineageGraph(callGraph, { ...(spec.opts || {}), perFile, fileContents: fc, signal });
43
+ }
44
+
45
+ /**
46
+ * @param {Object<string,string>} fileContents
47
+ * @param {object} [opts] buildLineageGraph options that survive structured clone,
48
+ * plus deadlineMs, ackGraceMs, signal (AbortSignal), and the test seam `worker`
49
+ * ({modulePath, exportName}) which replaces the worker body.
50
+ * @returns {{done:Promise<object>, exited:Promise<void>}} done resolves to the
51
+ * buildLineageGraph result shape, plus `cancelled` and, when the watchdog ended
52
+ * the worker, `terminatedByWatchdog` with status 'cancelled' and graph null.
53
+ */
54
+ export function buildLineageGraphOwned(fileContents, opts = {}) {
55
+ const t0 = Date.now();
56
+ // onProgress and perFile cannot cross the thread boundary; the worker rebuilds the IR.
57
+ // eslint-disable-next-line no-unused-vars
58
+ const { deadlineMs, ackGraceMs, signal, worker, onProgress, perFile, ...rest } = opts;
59
+ const timeoutMs = resolveLineageDeadlineMs({ deadlineMs });
60
+ const spec = worker || { modulePath: SELF, exportName: 'lineageWorkerBody' };
61
+ const run = runOwnedWorker(
62
+ { ...spec, args: [{ fileContents, opts: rest }] },
63
+ { signal, timeoutMs: timeoutMs || undefined, ackGraceMs },
64
+ );
65
+ const done = run.done.then((r) => {
66
+ if (r.ok) return { ...r.result, cancelled: !!r.cancelled, elapsedMs: Date.now() - t0 };
67
+ if (r.status === 'cancelled') {
68
+ return {
69
+ status: 'cancelled', graph: null, transitEvidence: new Map(),
70
+ failure: `lineage build cancelled (${r.reason}); worker terminated by watchdog`,
71
+ cancelled: true, terminatedByWatchdog: true, ackMs: r.ackMs, elapsedMs: Date.now() - t0,
72
+ };
73
+ }
74
+ return { status: 'failed', graph: null, transitEvidence: new Map(), failure: r.error, cancelled: false, elapsedMs: Date.now() - t0 };
75
+ });
76
+ return { done, exited: run.exited };
77
+ }
@@ -0,0 +1,22 @@
1
+ // Worker entry for owned-worker.js (DAG-07 R07.7). Same shape as
2
+ // pipeline/analyzer-worker.js, plus a shared cancellation flag the work can
3
+ // poll even from inside a synchronous loop (a posted message could not be
4
+ // delivered to a busy thread). The export is called as
5
+ // fn(...args, { shouldStop }).
6
+ import { parentPort, workerData } from 'node:worker_threads';
7
+
8
+ async function run() {
9
+ const flag = new Int32Array(workerData.flag);
10
+ const shouldStop = () => Atomics.load(flag, 0) === 1;
11
+ try {
12
+ const { modulePath, exportName, args } = workerData;
13
+ const mod = await import(modulePath);
14
+ const fn = mod[exportName];
15
+ if (typeof fn !== 'function') throw new Error(`export "${exportName}" is not a function in ${modulePath}`);
16
+ const result = await fn(...(args || []), { shouldStop });
17
+ parentPort.postMessage({ ok: true, cancelled: shouldStop(), result });
18
+ } catch (e) {
19
+ parentPort.postMessage({ ok: false, error: String((e && e.message) || e) });
20
+ }
21
+ }
22
+ run();
@@ -0,0 +1,95 @@
1
+ // owned-worker.js: run bounded work in a worker thread this module owns, with
2
+ // cooperative cancellation and a watchdog (DAG-07 R07.7).
3
+ //
4
+ // Follows the existing worker convention (pipeline/analyzer-supervisor.js:
5
+ // worker_threads, Worker.terminate() as the only preemptive tool) and adds
6
+ // the two things that module does not have:
7
+ // - cancellation on an AbortSignal, acknowledged through a shared flag the
8
+ // work polls (works from inside a synchronous loop);
9
+ // - a watchdog: if the worker has not acknowledged within `ackGraceMs` of
10
+ // the abort, it is terminated, so a worker that never polls cannot hold
11
+ // the caller past that bound.
12
+ // The returned promise always settles with a structured result, and every
13
+ // resource this function created (worker, timers, abort listener) is released
14
+ // before it settles; `exited` resolves when the worker thread is really gone.
15
+
16
+ import { Worker } from 'node:worker_threads';
17
+ import * as path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'owned-worker-entry.js');
21
+
22
+ export const DEFAULT_ACK_GRACE_MS = 500;
23
+
24
+ /**
25
+ * @param {{modulePath:string, exportName:string, args?:Array}} spec
26
+ * @param {{signal?:AbortSignal, timeoutMs?:number, ackGraceMs?:number}} [opts]
27
+ * @returns {{done:Promise<object>, exited:Promise<void>}}
28
+ * done resolves to one of:
29
+ * {ok:true, result, cancelled:false}
30
+ * {ok:true, result, cancelled:true, ackMs} cooperative stop
31
+ * {ok:false, status:'cancelled', terminatedByWatchdog:true, ackMs}
32
+ * {ok:false, error}
33
+ */
34
+ export function runOwnedWorker(spec, opts = {}) {
35
+ const ackGraceMs = Number.isFinite(opts.ackGraceMs) && opts.ackGraceMs > 0 ? opts.ackGraceMs : DEFAULT_ACK_GRACE_MS;
36
+ let resolveExited;
37
+ const exited = new Promise((r) => { resolveExited = r; });
38
+ const done = new Promise((resolve) => {
39
+ if (!spec || !spec.modulePath || !spec.exportName) {
40
+ resolveExited();
41
+ return resolve({ ok: false, error: 'runOwnedWorker requires spec.modulePath and spec.exportName' });
42
+ }
43
+ const flag = new Int32Array(new SharedArrayBuffer(4));
44
+ const worker = new Worker(ENTRY, { workerData: { modulePath: spec.modulePath, exportName: spec.exportName, args: spec.args || [], flag: flag.buffer } });
45
+ worker.once('exit', () => resolveExited());
46
+ let settled = false;
47
+ let cancelAt = null;
48
+ let reason = null;
49
+ let watchdog = null;
50
+ let deadlineTimer = null;
51
+
52
+ const release = () => {
53
+ clearTimeout(watchdog);
54
+ clearTimeout(deadlineTimer);
55
+ if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
56
+ worker.terminate().catch(() => {});
57
+ };
58
+ const settle = (value) => {
59
+ if (settled) return;
60
+ settled = true;
61
+ release();
62
+ resolve(value);
63
+ };
64
+ const requestCancel = (why) => {
65
+ if (settled || cancelAt !== null) return;
66
+ cancelAt = Date.now();
67
+ reason = why;
68
+ Atomics.store(flag, 0, 1);
69
+ watchdog = setTimeout(() => {
70
+ settle({ ok: false, status: 'cancelled', reason: why, terminatedByWatchdog: true, ackMs: Date.now() - cancelAt });
71
+ }, ackGraceMs);
72
+ };
73
+ function onAbort() { requestCancel('aborted'); }
74
+
75
+ worker.once('message', (msg) => {
76
+ if (msg && msg.ok) {
77
+ const wasCancelled = !!msg.cancelled || cancelAt !== null;
78
+ settle(wasCancelled
79
+ ? { ok: true, result: msg.result, cancelled: true, reason, ackMs: cancelAt === null ? 0 : Date.now() - cancelAt }
80
+ : { ok: true, result: msg.result, cancelled: false });
81
+ } else {
82
+ settle({ ok: false, error: (msg && msg.error) || 'worker failed' });
83
+ }
84
+ });
85
+ worker.once('error', (e) => settle({ ok: false, error: String((e && e.message) || e) }));
86
+
87
+ if (opts.signal) {
88
+ if (opts.signal.aborted) onAbort(); else opts.signal.addEventListener('abort', onAbort, { once: true });
89
+ }
90
+ if (Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0) {
91
+ deadlineTimer = setTimeout(() => requestCancel('deadline'), opts.timeoutMs);
92
+ }
93
+ });
94
+ return { done, exited };
95
+ }
@@ -0,0 +1,123 @@
1
+ //
2
+ // path-evidence.js: the DAG-04 evidence for witnesses, assembled (R04.1 to R04.6).
3
+ //
4
+ // One entry point per use:
5
+ // * evaluatePathEvidence one reconstructed path: conditions + feasibility + control state.
6
+ // * summarizeFlowEvidence a group of paths that share one graph flow: alternatives stay distinct,
7
+ // infeasible-under-model paths are listed (never silently dropped), and the
8
+ // all-paths claim goes through assessAllPathsProtectedWithControls.
9
+ // * buildControlEvidence the `controlEvidence` slot of a witness bundle (posture/witness-bundle.js).
10
+ //
11
+ // Everything here is opt-in. Nothing runs, and no field is added to any output, unless a caller (or the
12
+ // graph builder with `pathEvidence: true` / AGENTIC_SECURITY_LINEAGE_PATH_PREDICATES=1) asks for it.
13
+
14
+ import crypto from 'node:crypto';
15
+ import { collectWitnessConditions, evaluateWitnessConditions, PATH_PREDICATE_VERSION, SATISFIABILITY_NOTE } from './path-predicates.js';
16
+ import { evaluateWitnessConditionsAsync } from './smt-orchestrator.js';
17
+ import { creditPath, assessAllPathsProtectedWithControls, CONTROL_CREDIT_VERSION } from './control-credit.js';
18
+
19
+ export const PATH_EVIDENCE_VERSION = 'path-evidence/1';
20
+
21
+ export function pathEvidenceEnabled(opts) {
22
+ if (opts && opts.pathEvidence !== undefined) return Boolean(opts.pathEvidence);
23
+ return process.env.AGENTIC_SECURITY_LINEAGE_PATH_PREDICATES === '1';
24
+ }
25
+
26
+ const sha12 = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
27
+
28
+ function assemble(path, conditions, feasibility, credit) {
29
+ const refsByEdge = conditions.edgeGuards;
30
+ return {
31
+ version: PATH_EVIDENCE_VERSION,
32
+ pathId: path.id ?? null,
33
+ conditions, feasibility, credit,
34
+ controlState: credit.controlState,
35
+ edgeGuards: refsByEdge,
36
+ controlRefs: credit.credits.map((c) => ({ edgeId: c.edgeId, ref: `ctl:${sha12(`${c.scope}|${c.nodeId}|${c.callee}|${c.sinkFamily}`)}` })),
37
+ };
38
+ }
39
+
40
+ /** Synchronous evidence for one reconstructed path (abstract domains only). */
41
+ export function evaluatePathEvidence({ path, nodeOf, fnOf, sinkFamily = null, predicateOpts } = {}) {
42
+ const hops = path?.hops ?? [];
43
+ const conditions = collectWitnessConditions(hops, { fnOf });
44
+ const feasibility = evaluateWitnessConditions(conditions, predicateOpts);
45
+ const credit = creditPath({ hops, nodeOf, fnOf, sinkFamily, truncated: path?.complete === false || path?.analysisTruncated === true });
46
+ return assemble(path ?? {}, conditions, feasibility, credit);
47
+ }
48
+
49
+ /** As above, but an `unknown` abstract answer may be sent to an optional solver (awaited, time-bounded, cancellable). */
50
+ export async function evaluatePathEvidenceAsync({ path, nodeOf, fnOf, sinkFamily = null, predicateOpts, solverOpts } = {}) {
51
+ const hops = path?.hops ?? [];
52
+ const conditions = collectWitnessConditions(hops, { fnOf });
53
+ const feasibility = await evaluateWitnessConditionsAsync(conditions, { ...(predicateOpts ?? {}), ...(solverOpts ?? {}) });
54
+ const credit = creditPath({ hops, nodeOf, fnOf, sinkFamily, truncated: path?.complete === false || path?.analysisTruncated === true });
55
+ return assemble(path ?? {}, conditions, feasibility, credit);
56
+ }
57
+
58
+ const STATE_RANK = { unprotected: 0, unknown: 1, protected: 2 };
59
+
60
+ /**
61
+ * Summarize every path of one flow group. `evidences` is the per-path output of evaluatePathEvidence.
62
+ * The aggregate control state is the WORST over relevant paths, never the best, and alternatives are
63
+ * listed one by one with their own conjunction of conditions.
64
+ */
65
+ export function summarizeFlowEvidence({ result, paths, evidences, proof = null }) {
66
+ const byId = new Map(evidences.map((e, i) => [paths[i].id, e]));
67
+ const assessed = assessAllPathsProtectedWithControls({ result, enumerated: paths, evidenceOf: (p) => byId.get(p.id), proof });
68
+ const relevant = evidences.filter((e) => e.feasibility.status !== 'infeasible-under-model');
69
+ let worst = null;
70
+ for (const e of relevant) if (worst === null || STATE_RANK[e.controlState] < STATE_RANK[worst]) worst = e.controlState;
71
+ const feasCounts = {};
72
+ for (const e of evidences) feasCounts[e.feasibility.status] = (feasCounts[e.feasibility.status] ?? 0) + 1;
73
+ return {
74
+ version: PATH_EVIDENCE_VERSION,
75
+ semanticsVersion: PATH_PREDICATE_VERSION,
76
+ controlSemanticsVersion: CONTROL_CREDIT_VERSION,
77
+ pathCount: paths.length,
78
+ feasibility: feasCounts,
79
+ controlState: worst ?? 'unknown',
80
+ allPathsProtected: assessed,
81
+ alternatives: evidences.map((e, i) => ({
82
+ pathId: paths[i].id,
83
+ feasibility: e.feasibility.status,
84
+ feasibilityReasons: e.feasibility.reasons,
85
+ controlState: e.controlState,
86
+ predicateRefs: e.conditions.clauses.map((c) => ({ ref: c.ref, polarity: c.polarity, branchNodeId: c.branchNodeId, scope: c.scope, text: c.text, versions: c.versions })),
87
+ })),
88
+ note: SATISFIABILITY_NOTE,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The `controlEvidence` slot of a witness bundle (posture/witness-bundle.js validates its shape).
94
+ * Maps the five-valued feasibility result onto the slot's three statuses and keeps the precise result in
95
+ * `detail`, so `timeout` and `unsupported` stay visible as `unknown` rather than becoming a clean bill.
96
+ */
97
+ export function buildControlEvidence(evidence, { pathEdgeIds }) {
98
+ const f = evidence.feasibility;
99
+ const status = f.status === 'feasible-model' ? 'feasible' : f.status === 'infeasible-under-model' ? 'infeasible' : 'unknown';
100
+ const onPath = new Set(pathEdgeIds);
101
+ const predicateRefs = [];
102
+ for (const c of evidence.conditions.clauses) for (const edgeId of c.atEdgeIds) if (onPath.has(edgeId)) predicateRefs.push({ edgeId, ref: c.ref });
103
+ const controlRefs = evidence.controlRefs.filter((r) => onPath.has(r.edgeId));
104
+ return {
105
+ status,
106
+ predicateRefs: predicateRefs.sort((a, b) => (a.edgeId + a.ref < b.edgeId + b.ref ? -1 : 1)),
107
+ controlRefs,
108
+ detail: {
109
+ semanticsVersion: PATH_PREDICATE_VERSION,
110
+ feasibility: f.status,
111
+ reasons: f.reasons,
112
+ contradiction: f.contradiction ?? null,
113
+ unmodeledPredicates: f.unmodeledPredicates ?? [],
114
+ solver: f.solver ?? null,
115
+ limitations: f.limitations,
116
+ doesNotImply: SATISFIABILITY_NOTE,
117
+ controlState: evidence.controlState,
118
+ controlSemanticsVersion: CONTROL_CREDIT_VERSION,
119
+ controlsRejected: evidence.credit.rejected.map((r) => ({ edgeId: r.edgeId, callee: r.callee, reason: r.reason })),
120
+ controlLimitations: evidence.credit.limitations,
121
+ },
122
+ };
123
+ }