@clear-capabilities/agentic-security-scanner 0.184.0 → 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 (77) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -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/9579.index.js +1 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/dist/frontend/src/shell.js +12 -2
  11. package/package.json +26 -9
  12. package/src/dataflow/CLAUDE.md +43 -1
  13. package/src/dataflow/backward.js +42 -7
  14. package/src/dataflow/cross-service-taint.js +83 -1
  15. package/src/dataflow/demand-query.js +929 -0
  16. package/src/dataflow/dep-fingerprint.js +718 -0
  17. package/src/dataflow/engine.js +48 -6
  18. package/src/dataflow/ifds-precise.js +22 -0
  19. package/src/dataflow/incremental-replay.js +118 -0
  20. package/src/dataflow/incremental-session.js +257 -0
  21. package/src/dataflow/incremental.js +237 -35
  22. package/src/dataflow/index.js +25 -94
  23. package/src/dataflow/summaries.js +72 -6
  24. package/src/dataflow/tabulation.js +80 -24
  25. package/src/engine.js +51 -3
  26. package/src/graph/CLAUDE.md +31 -0
  27. package/src/graph/adapters/artifact.js +81 -0
  28. package/src/graph/adapters/crossrepo.js +75 -0
  29. package/src/graph/adapters/dataflow.js +76 -0
  30. package/src/graph/adapters/provenance.js +92 -0
  31. package/src/graph/adapters/semantic.js +89 -0
  32. package/src/graph/crosswalk-builders.js +152 -0
  33. package/src/graph/crosswalk.js +146 -0
  34. package/src/graph/index.js +15 -0
  35. package/src/graph/observation.js +57 -0
  36. package/src/graph/refs.js +105 -0
  37. package/src/graph/registry.js +129 -0
  38. package/src/graph/relations.js +81 -0
  39. package/src/graph/scc.js +241 -0
  40. package/src/graph/view.js +254 -0
  41. package/src/lineage/CLAUDE.md +71 -8
  42. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  43. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  44. package/src/lineage/boundary-contract.js +273 -0
  45. package/src/lineage/completion.js +205 -0
  46. package/src/lineage/continuation.js +140 -0
  47. package/src/lineage/control-credit.js +313 -0
  48. package/src/lineage/correlation.js +199 -0
  49. package/src/lineage/coverage.js +8 -1
  50. package/src/lineage/cross-repo-link.js +22 -0
  51. package/src/lineage/cut-planner.js +548 -0
  52. package/src/lineage/driver.js +219 -66
  53. package/src/lineage/engine.js +166 -21
  54. package/src/lineage/export-json.js +5 -0
  55. package/src/lineage/graph-builder.js +84 -7
  56. package/src/lineage/haskell-view.js +59 -0
  57. package/src/lineage/index.js +3 -0
  58. package/src/lineage/nix-view.js +35 -0
  59. package/src/lineage/owned-build.js +77 -0
  60. package/src/lineage/owned-worker-entry.js +22 -0
  61. package/src/lineage/owned-worker.js +95 -0
  62. package/src/lineage/path-evidence.js +123 -0
  63. package/src/lineage/path-predicates.js +529 -0
  64. package/src/lineage/path-query.js +189 -20
  65. package/src/lineage/path-store.js +191 -17
  66. package/src/lineage/scc-solver.js +186 -0
  67. package/src/lineage/smt-orchestrator.js +138 -0
  68. package/src/lineage/source-seeding.js +36 -2
  69. package/src/lineage/summaries.js +66 -4
  70. package/src/lineage/value-versions.js +130 -0
  71. package/src/lineage/witness-selection.js +95 -0
  72. package/src/pipeline/scan-health.js +18 -0
  73. package/src/posture/CLAUDE.md +13 -0
  74. package/src/posture/evidence-bundle.js +1 -1
  75. package/src/posture/witness-bundle.js +573 -0
  76. package/src/posture/witness-runtime.js +276 -0
  77. package/src/report/index.js +2 -0
@@ -35,42 +35,77 @@ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
35
35
 
36
36
  const FILES_PATH = 'files.json';
37
37
  const SUMMARIES_PATH = 'summaries.json';
38
- const VERSION_PATH = 'version.json';
38
+ const VERSION_PATH = 'version.json'; // the MANIFEST: written last, names and digests every data file
39
+ const DEPS_PATH = 'deps.json'; // DAG-05: dependency model + global components
40
+ const REPLAY_PATH = 'replay.json'; // DAG-05: typed contexts, exit states, per-function findings
41
+ const DATA_FILES = [FILES_PATH, SUMMARIES_PATH, DEPS_PATH, REPLAY_PATH];
39
42
  const MAX_PERSISTED_SUMMARIES = 50000;
43
+ // DAG-05 R05.6: bump when the on-disk shape changes; an older or newer record is rejected, never guessed at.
44
+ export const INCREMENTAL_FORMAT = 'incremental-state/2';
40
45
 
41
46
  /** Compute the content hash used for file-equality detection. */
42
47
  export function hashFileContent(stripped) {
43
48
  return crypto.createHash('sha256').update(stripped || '').digest('hex');
44
49
  }
45
50
 
46
- /** Read the persisted state. Returns a fresh empty state on any error. */
51
+ /**
52
+ * Read the persisted state. Returns a fresh empty state on any error.
53
+ *
54
+ * DAG-05 R05.6: the version file is a manifest that names every data file with
55
+ * its SHA-256. A state is accepted only when the format is the current one, every
56
+ * named file exists and matches its digest and parses, so a crash between the
57
+ * temp write and the rename, a truncated file or a hand edit is rejected as a
58
+ * whole. A rejection is carried as `state.rejected` (the reason) so the caller
59
+ * can say why it fell back to cold analysis.
60
+ */
47
61
  export function readIncrementalState(projectRoot) {
48
62
  const dir = statePath(projectRoot, 'incremental');
63
+ const versionFp = path.join(dir, VERSION_PATH);
64
+ let manifest;
49
65
  try {
50
- const versionFp = path.join(dir, VERSION_PATH);
51
66
  if (!fs.existsSync(versionFp)) return _emptyState();
52
- const v = JSON.parse(fs.readFileSync(versionFp, 'utf8'));
53
- return {
54
- version: v,
55
- files: _readJsonOrEmpty(path.join(dir, FILES_PATH), {}),
56
- summaries: _readJsonOrEmpty(path.join(dir, SUMMARIES_PATH), {}),
57
- };
67
+ manifest = JSON.parse(fs.readFileSync(versionFp, 'utf8'));
58
68
  } catch (_e) {
59
- return _emptyState();
69
+ return _rejected('cache manifest is corrupt (does not parse)');
70
+ }
71
+ if (!manifest || typeof manifest !== 'object' || manifest.format !== INCREMENTAL_FORMAT) {
72
+ return _rejected(`cache format is not ${INCREMENTAL_FORMAT} (found ${manifest && manifest.format ? manifest.format : 'legacy'})`);
73
+ }
74
+ const listed = manifest.files && typeof manifest.files === 'object' ? manifest.files : null;
75
+ if (!listed || !manifest.version || typeof manifest.version !== 'object') return _rejected('cache manifest is incomplete');
76
+ const parsed = {};
77
+ for (const name of DATA_FILES) {
78
+ const entry = listed[name];
79
+ if (!entry || typeof entry.sha256 !== 'string') return _rejected(`cache manifest does not list ${name}`);
80
+ let text;
81
+ try { text = fs.readFileSync(path.join(dir, name), 'utf8'); }
82
+ catch { return _rejected(`incomplete cache: ${name} is missing`); }
83
+ const got = crypto.createHash('sha256').update(text).digest('hex');
84
+ if (got !== entry.sha256) return _rejected(`cache record ${name} failed its integrity digest (interrupted write or corruption)`);
85
+ try { parsed[name] = JSON.parse(text); }
86
+ catch { return _rejected(`cache record ${name} does not parse`); }
60
87
  }
88
+ if (!parsed[FILES_PATH] || typeof parsed[FILES_PATH] !== 'object' || Array.isArray(parsed[FILES_PATH])) return _rejected('cache record files.json has the wrong shape');
89
+ if (!parsed[SUMMARIES_PATH] || typeof parsed[SUMMARIES_PATH] !== 'object') return _rejected('cache record summaries.json has the wrong shape');
90
+ if (parsed[DEPS_PATH] !== null && (typeof parsed[DEPS_PATH] !== 'object' || (parsed[DEPS_PATH] && parsed[DEPS_PATH].schema !== 'dep-graph/1'))) {
91
+ return _rejected('cache dependency graph has an incompatible schema');
92
+ }
93
+ return {
94
+ version: manifest.version,
95
+ generation: manifest.generation || null,
96
+ files: parsed[FILES_PATH],
97
+ summaries: parsed[SUMMARIES_PATH],
98
+ deps: parsed[DEPS_PATH],
99
+ replay: parsed[REPLAY_PATH],
100
+ };
61
101
  }
62
102
 
63
103
  function _emptyState() {
64
- return { version: null, files: {}, summaries: {} };
104
+ return { version: null, files: {}, summaries: {}, deps: null, replay: null };
65
105
  }
66
106
 
67
- function _readJsonOrEmpty(fp, fallback) {
68
- try {
69
- if (!fs.existsSync(fp)) return fallback;
70
- return JSON.parse(fs.readFileSync(fp, 'utf8'));
71
- } catch (_e) {
72
- return fallback;
73
- }
107
+ function _rejected(reason) {
108
+ return { ..._emptyState(), rejected: reason };
74
109
  }
75
110
 
76
111
  /**
@@ -78,6 +113,7 @@ function _readJsonOrEmpty(fp, fallback) {
78
113
  * Returns `{ valid: boolean, reason?: string }`.
79
114
  */
80
115
  export function validateIncrementalState(state, currentVersion) {
116
+ if (state && state.rejected) return { valid: false, reason: state.rejected };
81
117
  if (!state || !state.version) return { valid: false, reason: 'no prior state' };
82
118
  if (!currentVersion) return { valid: false, reason: 'no current version' };
83
119
  if (state.version.scanner !== currentVersion.scanner) return { valid: false, reason: 'scanner version changed' };
@@ -163,7 +199,19 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
163
199
  mutatedThisFields: new Set(s.mutatedThisFields || []),
164
200
  taintedGlobals: new Set(s.taintedGlobals || []),
165
201
  findings: Array.isArray(s.findings) ? s.findings : [],
202
+ // DAG-07 R07.3: provenance of a restored summary. Never serialized
203
+ // (serializeSummaries copies named fields only), so it cannot leak back
204
+ // into the persisted state; SummaryCache reads it to tell a restored
205
+ // empty-entry summary standing in for a tainted context from a computed one.
206
+ _incrementalSeed: true,
166
207
  };
208
+ if (Number.isInteger(s.findingsTruncated) && s.findingsTruncated > 0) {
209
+ // Findings beyond the persist cap were dropped when this was saved, so
210
+ // reuse is a partial replay of what the function reported.
211
+ summary._findingsTruncated = s.findingsTruncated;
212
+ if (summaryCache._incrementalFindingsTruncated === undefined) summaryCache._incrementalFindingsTruncated = 0;
213
+ summaryCache._incrementalFindingsTruncated += 1;
214
+ }
167
215
  // Use the bottom taint-state key — these are summaries that DON'T depend
168
216
  // on entry taint state (e.g., pure functions). Higher-fidelity reuse
169
217
  // would require persisting the entry-state hash too; deferred.
@@ -198,59 +246,213 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
198
246
  export function serializeSummaries(summaryCache) {
199
247
  const out = {};
200
248
  if (!summaryCache || !summaryCache._cache) return out;
249
+ // DAG-07 R07.3. The cache key is `<qid>::<entryHash>[@caller][::receiver]`
250
+ // and a qid itself contains `::`, so the old `key.split('::')[0]` reduced
251
+ // every key to its FILE and let whichever entry context was written last
252
+ // stand for the whole file. Only the EMPTY-entry summary is a statement
253
+ // about what a function does when nothing tainted flows in; a summary
254
+ // computed under a tainted entry must never be restored as that. Parse the
255
+ // key from the right, persist the empty-entry summary under the real qid,
256
+ // and count what was not persisted so reuse can be reported honestly.
257
+ const EMPTY_SUFFIX = '::empty';
201
258
  let count = 0;
259
+ const skippedContexts = new Map();
202
260
  for (const [key, summary] of summaryCache._cache) {
203
- if (count >= MAX_PERSISTED_SUMMARIES) break;
204
261
  if (!summary || summary._budgetExceeded || summary._recursive) continue;
205
- const qid = key.split('::')[0];
262
+ if (!key.endsWith(EMPTY_SUFFIX)) {
263
+ const m = /^(.*?::[^:]*@\d+)::/.exec(key);
264
+ const q = m ? m[1] : key;
265
+ skippedContexts.set(q, (skippedContexts.get(q) || 0) + 1);
266
+ continue;
267
+ }
268
+ const qid = key.slice(0, -EMPTY_SUFFIX.length);
206
269
  if (!qid) continue;
270
+ if (count >= MAX_PERSISTED_SUMMARIES) break;
271
+ const findings = Array.isArray(summary.findings) ? summary.findings : [];
207
272
  out[qid] = {
208
273
  returnTainted: !!summary.returnTainted,
209
274
  mutatedParams: [...(summary.mutatedParams || [])],
210
275
  mutatedThisFields: [...(summary.mutatedThisFields || [])],
211
276
  taintedGlobals: [...(summary.taintedGlobals || [])],
212
- findings: Array.isArray(summary.findings) ? summary.findings.slice(0, 50) : [],
277
+ findings: findings.slice(0, 50),
278
+ entryContext: 'empty',
279
+ ...(findings.length > 50 ? { findingsTruncated: findings.length - 50 } : {}),
213
280
  };
214
281
  count++;
215
282
  }
283
+ for (const [q, n] of skippedContexts) if (out[q]) out[q].contextsNotPersisted = n;
216
284
  return out;
217
285
  }
218
286
 
287
+ // ── DAG-05 R05.3: typed, context-keyed persistence ──────────────────────────
288
+
289
+ /**
290
+ * A copy of a finding that survives JSON. An expression-object `callee` is kept as a structural copy:
291
+ * it is plain data, equal by value to the original, and only the backward slicer compares it by
292
+ * reference (replay is refused under that option). Returns null when the value cannot be serialised
293
+ * at all (a cycle), which makes the owner non-replayable rather than lossy.
294
+ */
295
+ export function jsonSafeFinding(f) {
296
+ try { return JSON.parse(JSON.stringify(f)); } catch { return null; }
297
+ }
298
+
299
+ export const CONTEXT_SCHEMA = 'summary-context/1';
300
+ const MAX_FINDINGS_PER_CONTEXT = 200;
301
+
302
+ /**
303
+ * Every computed context of every function, each under its COMPLETE key: qid,
304
+ * the canonical entry-taint list, the state hash derived from it and the
305
+ * receiver hash. A context is either persisted whole or counted in `skipped`
306
+ * with the reason; it is never reduced to the empty-entry slot.
307
+ *
308
+ * { schema, contexts: { qid: [ { entry:[...], stateHash, recv, summary } ] }, skipped: { reason: n } }
309
+ */
310
+ export function serializeContexts(summaryCache) {
311
+ const contexts = {};
312
+ const skipped = {};
313
+ const note = (r) => { skipped[r] = (skipped[r] || 0) + 1; };
314
+ if (!summaryCache || !summaryCache._cache) return { schema: CONTEXT_SCHEMA, contexts, skipped };
315
+ const meta = summaryCache._meta;
316
+ let count = 0;
317
+ for (const [key, summary] of summaryCache._cache) {
318
+ if (!summary) continue;
319
+ if (summary._budgetExceeded) { note('budget-exceeded'); continue; }
320
+ if (summary._recursive) { note('recursion-stub'); continue; }
321
+ const m = meta && meta.get(key);
322
+ if (!m) { note('no-key-metadata'); continue; }
323
+ const findings = Array.isArray(summary.findings) ? summary.findings : [];
324
+ if (findings.length > MAX_FINDINGS_PER_CONTEXT) { note('findings-over-cap'); continue; }
325
+ const safe = [];
326
+ let ok = true;
327
+ for (const f of findings) { const c = jsonSafeFinding(f); if (c === null) { ok = false; break; } safe.push(c); }
328
+ if (!ok) { note('findings-not-serialisable'); continue; }
329
+ if (count >= MAX_PERSISTED_SUMMARIES) { note('persist-cap'); continue; }
330
+ (contexts[m.qid] ||= []).push({
331
+ entry: m.entry, stateHash: m.stateHash, recv: m.recv || null,
332
+ summary: {
333
+ returnTainted: !!summary.returnTainted,
334
+ mutatedParams: [...(summary.mutatedParams || [])].sort(),
335
+ mutatedThisFields: [...(summary.mutatedThisFields || [])].sort(),
336
+ taintedGlobals: [...(summary.taintedGlobals || [])].sort(),
337
+ findings: safe,
338
+ },
339
+ });
340
+ count++;
341
+ }
342
+ for (const q of Object.keys(contexts)) contexts[q].sort((a, b) => (a.stateHash + (a.recv || '') < b.stateHash + (b.recv || '') ? -1 : 1));
343
+ return { schema: CONTEXT_SCHEMA, contexts, skipped };
344
+ }
345
+
346
+ /**
347
+ * Restore persisted contexts for the given function ids. Each record is checked
348
+ * (shape, entry list reproduces its state hash) before it is stored under its
349
+ * real key; a record that fails is counted and ignored, so the function simply
350
+ * recomputes that context. Returns { restored, rejected }.
351
+ */
352
+ export function restoreContexts(summaryCache, persisted, qids) {
353
+ let restored = 0; let rejected = 0;
354
+ if (!summaryCache || !persisted || persisted.schema !== CONTEXT_SCHEMA || !persisted.contexts) return { restored, rejected };
355
+ for (const qid of qids) {
356
+ for (const rec of persisted.contexts[qid] || []) {
357
+ const s = rec && rec.summary;
358
+ if (!s || !Array.isArray(rec.entry) || typeof rec.stateHash !== 'string' || !Array.isArray(s.findings)) { rejected++; continue; }
359
+ const summary = {
360
+ returnTainted: !!s.returnTainted,
361
+ mutatedParams: new Set(s.mutatedParams || []),
362
+ mutatedThisFields: new Set(s.mutatedThisFields || []),
363
+ taintedGlobals: new Set(s.taintedGlobals || []),
364
+ findings: s.findings,
365
+ };
366
+ if (summaryCache.restoreContext(qid, rec.entry, rec.recv || null, rec.stateHash, summary)) restored++; else rejected++;
367
+ }
368
+ }
369
+ return { restored, rejected };
370
+ }
371
+
219
372
  /**
220
- * Commit incremental state to disk. Idempotent — safe to call from anywhere.
373
+ * Commit incremental state to disk. Idempotent: safe to call from anywhere.
221
374
  *
222
375
  * state.files { filepath: sha256 }
223
376
  * state.summaries { qid: summary } (output of serializeSummaries)
224
377
  * state.callers { qid: [callerQid] } (reverse call-graph)
378
+ * state.deps DAG-05 dependency model + global components (or null)
379
+ * state.replay DAG-05 typed contexts / exit states / findings (or null)
225
380
  * currentVersion { scanner, rules }
381
+ *
382
+ * DAG-05 R05.6: atomic. Every data file is written to a uniquely named temp file
383
+ * and fsynced, then renamed over its final name, and the manifest (version.json,
384
+ * which holds each file's SHA-256) is renamed LAST. A crash anywhere in that
385
+ * sequence leaves either the previous manifest with data it no longer matches, or
386
+ * a half-renamed set; `readIncrementalState` rejects both whole, so the next scan
387
+ * is cold, never a mixture of two generations. `io` is a seam for tests that
388
+ * simulate the crash (`io.renameSync`).
226
389
  */
227
- export function commitIncrementalState(projectRoot, state, currentVersion) {
390
+ export function commitIncrementalState(projectRoot, state, currentVersion, io = {}) {
228
391
  if (!projectRoot) return false;
229
392
  const dir = statePath(projectRoot, 'incremental');
393
+ const rename = io.renameSync || fs.renameSync;
394
+ const gen = crypto.randomBytes(6).toString('hex');
395
+ const temps = [];
230
396
  try {
231
- if (!stateWritesEnabled()) return;
232
- fs.mkdirSync(dir, { recursive: true });
233
- fs.writeFileSync(path.join(dir, VERSION_PATH), JSON.stringify(currentVersion, null, 2));
234
- fs.writeFileSync(path.join(dir, FILES_PATH), JSON.stringify(state.files || {}, null, 2));
235
- const payload = {
236
- summaries: state.summaries || {},
237
- callers: state.callers || {},
397
+ if (!stateWritesEnabled()) return false;
398
+ fs.mkdirSync(dir, { recursive: true });
399
+ const payloads = {
400
+ [FILES_PATH]: state.files || {},
401
+ [SUMMARIES_PATH]: { summaries: state.summaries || {}, callers: state.callers || {} },
402
+ [DEPS_PATH]: state.deps || null,
403
+ [REPLAY_PATH]: state.replay || null,
238
404
  };
239
- fs.writeFileSync(path.join(dir, SUMMARIES_PATH), JSON.stringify(payload));
405
+ const listed = {};
406
+ for (const name of DATA_FILES) {
407
+ const text = JSON.stringify(payloads[name]);
408
+ listed[name] = { sha256: crypto.createHash('sha256').update(text).digest('hex'), bytes: Buffer.byteLength(text) };
409
+ const tmp = path.join(dir, `${name}.tmp-${gen}`);
410
+ temps.push(tmp);
411
+ _writeFileTemp(tmp, text);
412
+ }
413
+ const manifest = { format: INCREMENTAL_FORMAT, generation: gen, version: currentVersion, files: listed };
414
+ const manifestTmp = path.join(dir, `${VERSION_PATH}.tmp-${gen}`);
415
+ temps.push(manifestTmp);
416
+ _writeFileTemp(manifestTmp, JSON.stringify(manifest, null, 2));
417
+ for (const name of DATA_FILES) rename(path.join(dir, `${name}.tmp-${gen}`), path.join(dir, name));
418
+ rename(manifestTmp, path.join(dir, VERSION_PATH));
419
+ _sweepTemps(dir, gen);
240
420
  return true;
241
421
  } catch (_e) {
422
+ // Leave whatever the crash left: the reader rejects an inconsistent set. Only
423
+ // our own temp files are removed, and only the ones not yet renamed.
424
+ for (const t of temps) { try { if (fs.existsSync(t)) fs.unlinkSync(t); } catch { /* best effort */ } }
242
425
  return false;
243
426
  }
244
427
  }
245
428
 
246
- /** Drop persisted state — used when a version mismatch is detected. */
429
+ // No fsync, on purpose: the reader checks the SHA-256 of every file the manifest names, so a write
430
+ // that did not reach the disk (power loss) is detected and rejected whole and the next scan is cold.
431
+ // Durability is not what protects the cache; the digest check is. An fsync per file cost more than
432
+ // the analysis it saved on a small edit.
433
+ function _writeFileTemp(fp, text) {
434
+ fs.writeFileSync(fp, text, { mode: 0o600 });
435
+ }
436
+
437
+ /** Remove temp files left by an earlier interrupted commit (never the current one's). */
438
+ function _sweepTemps(dir, keepGen) {
439
+ try {
440
+ for (const n of fs.readdirSync(dir)) {
441
+ if (/\.tmp-[0-9a-f]+$/.test(n) && !n.endsWith(`.tmp-${keepGen}`)) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* best effort */ } }
442
+ }
443
+ } catch { /* best effort */ }
444
+ }
445
+
446
+ /** Drop persisted state, used when a version mismatch is detected. */
247
447
  export function dropIncrementalState(projectRoot) {
248
448
  const dir = statePath(projectRoot, 'incremental');
249
449
  try {
250
450
  if (!fs.existsSync(dir)) return true;
251
- for (const fn of [VERSION_PATH, FILES_PATH, SUMMARIES_PATH]) {
252
- const fp = path.join(dir, fn);
253
- if (fs.existsSync(fp)) fs.unlinkSync(fp);
451
+ for (const fn of fs.readdirSync(dir)) {
452
+ if (DATA_FILES.includes(fn) || fn === VERSION_PATH || /\.tmp-[0-9a-f]+$/.test(fn)) {
453
+ const fp = path.join(dir, fn);
454
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
455
+ }
254
456
  }
255
457
  return true;
256
458
  } catch (_e) {
@@ -6,11 +6,9 @@ import { applyPathFeasibility, buildConstantFnMap } from './path-feasibility.js'
6
6
  import { SummaryCache, entryStateFromCall } from './summaries.js';
7
7
  import { rhsReachableFunctions, shouldAnalyzeUnderRhs } from './tabulation.js';
8
8
  import { annotateBackwardSlices } from './backward.js';
9
- import {
10
- readIncrementalState, validateIncrementalState, diffFileHashes,
11
- hashFileContent, pickReusableSummaries, seedSummaryCache,
12
- serializeSummaries, commitIncrementalState,
13
- } from './incremental.js';
9
+ // DAG-05: dependency-complete incremental analysis (see incremental-session.js).
10
+ import { beginIncrementalSession, engineOptionsFor, finishIncrementalSession } from './incremental-session.js';
11
+ import { demandEnabled, runDemandQueries } from './demand-query.js'; // DAG-02
14
12
  import { buildPointsTo } from './points-to.js';
15
13
  import { buildClassHierarchy, annotateRTA } from '../ir/class-hierarchy.js';
16
14
  import { annotateSoftTaint } from './soft-taint.js';
@@ -34,57 +32,15 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
34
32
  // P2.1 — RHS-lite reachability slice. When AGENTIC_SECURITY_RHS=1 the
35
33
  // engine narrows analysis to sink-reachable functions. Default OFF
36
34
  // because it changes the finding-set composition.
35
+ let rhsCompletion = null;
37
36
  if (process.env.AGENTIC_SECURITY_RHS === '1') {
38
37
  const ctx = rhsReachableFunctions(perFileIR, callGraph);
38
+ rhsCompletion = ctx.completion || null;
39
39
  if (ctx.reachable) {
40
40
  opts = { ...opts, _rhsReachable: ctx.reachable, _rhsCheck: shouldAnalyzeUnderRhs };
41
41
  }
42
42
  }
43
- // v0.69 — cross-scan incremental cache (AGENTIC_SECURITY_INCREMENTAL=1).
44
- // Read persisted state, seed the SummaryCache with summaries from files
45
- // whose content hasn't changed, then hand it to runTaintEngine. After,
46
- // serialize the cache and commit to disk.
47
43
  const incrementalEnabled = process.env.AGENTIC_SECURITY_INCREMENTAL === '1';
48
- let preSeededCache = null;
49
- let priorState = null;
50
- let currentFileHashes = null;
51
- if (incrementalEnabled && opts.scanRoot && opts.fileContents) {
52
- priorState = readIncrementalState(opts.scanRoot);
53
- const currentVersion = {
54
- scanner: opts.scannerVersion || 'unknown',
55
- rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
56
- };
57
- // Compute current file hashes UNCONDITIONALLY — the first scan (and any
58
- // scan after a version/rule invalidation) must persist a baseline so the
59
- // NEXT scan has something to reuse. This previously lived inside the
60
- // valid-state branch, so a cold cache never committed and incremental was
61
- // a permanent no-op (the commit guard at the bottom keys on currentFileHashes).
62
- currentFileHashes = {};
63
- for (const [fp, content] of Object.entries(opts.fileContents)) {
64
- currentFileHashes[fp] = hashFileContent(content);
65
- }
66
- const valid = validateIncrementalState(priorState, currentVersion);
67
- if (valid.valid) {
68
- const diff = diffFileHashes(priorState.files || {}, currentFileHashes);
69
- const changedQids = new Set();
70
- // Map a changed file to the qids it owns. perFileIR exposes file→fns.
71
- for (const fp of [...diff.changed, ...diff.added, ...diff.removed]) {
72
- const ir = perFileIR[fp];
73
- if (!ir) continue;
74
- for (const fn of (ir.functions || [])) changedQids.add(fn.qid);
75
- }
76
- const persistedPayload = (priorState.summaries && priorState.summaries.summaries) || priorState.summaries || {};
77
- const callerOfQid = (priorState.summaries && priorState.summaries.callers) || {};
78
- const { reusable } = pickReusableSummaries(persistedPayload, callerOfQid, changedQids);
79
- preSeededCache = new SummaryCache();
80
- const seededN = seedSummaryCache(preSeededCache, persistedPayload, reusable);
81
- preSeededCache._incrementalSeeded = seededN;
82
- preSeededCache._incrementalReusable = reusable.size;
83
- } else {
84
- // Stale → caller should drop; we just don't seed.
85
- priorState = null;
86
- }
87
- }
88
44
  // PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): Class Hierarchy
89
45
  // Analysis. Built once per scan (mirrors the points-to graph immediately
90
46
  // below) and threaded through opts so the taint engine can (a) narrow
@@ -119,12 +75,18 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
119
75
  // Runs the formal Reps-Horwitz-Sagiv tabulation in parallel with the
120
76
  // worklist engine. We MERGE findings — the IFDS solver may catch
121
77
  // context-sensitive flows the k=2 cache joined out. Deduped by sink+line.
78
+ // DAG-05: the session decides warm or cold from content fingerprints (it needs the
79
+ // call graph and class hierarchy built above) and restores typed contexts.
80
+ const incrementalSession = beginIncrementalSession({ enabled: incrementalEnabled, opts, perFileIR, callGraph, cha: classHierarchy });
122
81
  let findings = runTaintEngine(perFileIR, callGraph, {
123
82
  ...opts,
124
- summaryCache: preSeededCache || undefined,
83
+ ...engineOptionsFor(incrementalSession),
125
84
  _pointsTo: pointsToGraph || undefined,
126
85
  _cha: classHierarchy || undefined,
127
86
  });
87
+ const engineOutput = findings;
88
+ // DAG-05: provenance + persistence happen right after the engine, on the engine's own array.
89
+ const incrementalStats = finishIncrementalSession(incrementalSession, engineOutput);
128
90
  if (process.env.AGENTIC_SECURITY_IFDS === '1') {
129
91
  try {
130
92
  const ifdsFindings = runIfdsTaintEngine(perFileIR, callGraph, opts);
@@ -152,16 +114,20 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
152
114
  }
153
115
  } catch { /* privacy-deep failure should not fail the scan */ }
154
116
  }
117
+ // DAG-02 begin (demand-driven sink queries; opt-in, additive, never changes `findings`)
118
+ if (demandEnabled() && Array.isArray(opts.demandQueries) && opts.demandQueries.length) {
119
+ try {
120
+ const demandResults = runDemandQueries(perFileIR, callGraph, opts.demandQueries, { ...(opts.demandOpts || {}), engineOpts: { ...opts, _cha: classHierarchy || undefined, _pointsTo: pointsToGraph || undefined } });
121
+ Object.defineProperty(findings, '_demandResults', { value: demandResults, enumerable: false });
122
+ } catch { /* demand queries must never fail the scan */ }
123
+ }
124
+ // DAG-02 end
155
125
  for (const f of findings) f._pathFeasibilityPruned = totalPruned;
156
- if (preSeededCache) {
157
- Object.defineProperty(findings, '_incrementalStats', {
158
- value: {
159
- seeded: preSeededCache._incrementalSeeded || 0,
160
- reusable: preSeededCache._incrementalReusable || 0,
161
- },
162
- enumerable: false,
163
- });
126
+ if (incrementalStats) {
127
+ Object.defineProperty(findings, '_incrementalStats', { value: incrementalStats, enumerable: false });
128
+ if (findings !== engineOutput) Object.defineProperty(engineOutput, '_incrementalStats', { value: incrementalStats, enumerable: false });
164
129
  }
130
+ if (rhsCompletion) Object.defineProperty(findings, '_rhsCompletion', { value: rhsCompletion, enumerable: false });
165
131
  // P1.4 — backward slice (opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1).
166
132
  // Next-gen taint capability #5 (SMT path-feasibility rebuild): ALSO run
167
133
  // this when AGENTIC_SECURITY_SYMEXEC=1 is set, even if the operator never
@@ -221,43 +187,8 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
221
187
  }
222
188
  } catch { /* prover failure should not fail the scan */ }
223
189
  }
224
- // v0.69 — commit incremental state after a successful scan.
225
- if (incrementalEnabled && opts.scanRoot && currentFileHashes) {
226
- const cache = findings._summaryCache;
227
- const summaries = cache ? serializeSummaries(cache) : {};
228
- // Reverse call-graph (qid → callers) — derive from callGraph.
229
- const callers = {};
230
- if (callGraph && callGraph.functions) {
231
- for (const fn of callGraph.functions.values()) {
232
- if (!Array.isArray(fn.calls)) continue;
233
- for (const c of fn.calls) {
234
- // fn.calls entries are objects ({site, callee, args, line}); using one
235
- // as an object key stringifies it to "[object Object]" and collapses
236
- // every entry into a single bogus bucket. Key by the RESOLVED qid so
237
- // this map is queryable by the qids the worklist actually uses.
238
- const name = c && typeof c === 'object' ? c.callee : c;
239
- if (!name) continue;
240
- // resolveKnownCallee (not resolve): a raw fn.calls name may be a
241
- // flattened member-call string ("loader.read"); the plain
242
- // resolve() would guess via its bare-tail fallback and invent an
243
- // edge to an unrelated same-named function. See callgraph.js.
244
- const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
245
- if (!qid) continue;
246
- if (!callers[qid]) callers[qid] = [];
247
- callers[qid].push(fn.qid);
248
- }
249
- }
250
- }
251
- commitIncrementalState(opts.scanRoot, {
252
- files: currentFileHashes,
253
- summaries,
254
- callers,
255
- }, {
256
- scanner: opts.scannerVersion || 'unknown',
257
- rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
258
- });
259
- }
260
190
  return findings;
261
191
  }
262
192
 
193
+ export { demandEnabled, runDemandQueries } from './demand-query.js'; // DAG-02
263
194
  export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, buildConstantFnMap, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
@@ -52,12 +52,22 @@ function _hashState(taintedParams) {
52
52
  return crypto.createHash('sha256').update(sorted).digest('hex').slice(0, 12);
53
53
  }
54
54
 
55
+ // DAG-05 R05.3: the canonical entry list a key was derived from, so a persisted
56
+ // context can carry its COMPLETE key (qid, entry state, receiver) and be checked
57
+ // on reload rather than trusted by hash alone.
58
+ function entryListOf(taintedParams) {
59
+ if (!taintedParams || taintedParams.size === 0) return [];
60
+ return [...canonicalizeAccessSet(taintedParams)].sort();
61
+ }
62
+
55
63
  export class SummaryCache {
56
64
  constructor() {
57
65
  this._cache = new Map(); // qid::hash → summary
58
66
  this._stack = new Set(); // qids currently being analyzed (recursion guard)
59
67
  this._iter = 0;
60
- this._maxIter = 5000;
68
+ // Computation budget for one scan. AGENTIC_SECURITY_SUMMARY_BUDGET raises (or lowers) it; the default is unchanged.
69
+ const envBudget = Number(process.env.AGENTIC_SECURITY_SUMMARY_BUDGET);
70
+ this._maxIter = Number.isFinite(envBudget) && envBudget > 0 ? Math.floor(envBudget) : 5000;
61
71
  // FR-SEM-2 (roadmap #2): the engine computes a distinct summary per
62
72
  // entry-taint-state (value-context sensitivity), computed lazily at call
63
73
  // sites. To stop that from blowing up when a hot helper is reached under
@@ -70,6 +80,13 @@ export class SummaryCache {
70
80
  const envCap = Number(process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS);
71
81
  this._maxContextsPerFn = Number.isFinite(envCap) && envCap >= 0 ? envCap : 16;
72
82
  this._contextCapHits = 0;
83
+ // DAG-07 R07.3: append-only record of every place a result is coarser
84
+ // than a fresh analysis would give: a tainted entry answered with the
85
+ // empty-entry summary (context cap), a recursion refinement that was still
86
+ // changing when its rounds ran out, and the subset of cap fallbacks whose
87
+ // empty-entry summary was restored from a previous scan rather than
88
+ // computed in this one.
89
+ this._notes = [];
73
90
  // R2 (PRD §5): bounded k=1 call-string sensitivity. OPT-IN (default OFF).
74
91
  // When on, the cache key is extended with the immediate caller's qid, so two
75
92
  // call paths reaching a helper with the SAME tainted-arg shape get DISTINCT
@@ -78,6 +95,9 @@ export class SummaryCache {
78
95
  // before, so no behavior change for the shipped engine.
79
96
  this._callString = process.env.AGENTIC_SECURITY_KCFA_CALLSTRING === '1';
80
97
  this._callerCtx = null;
98
+ // DAG-05 R05.3: key -> { qid, entry:[...], stateHash, recv } for every key this
99
+ // cache has produced, so cross-scan persistence can store typed contexts.
100
+ this._meta = new Map();
81
101
  }
82
102
 
83
103
  // Set the immediate-caller qid for call-string keying; returns the previous
@@ -111,8 +131,38 @@ export class SummaryCache {
111
131
  // caller dimension there only ever narrows an already-isolated lookup,
112
132
  // never disconnects a shared one.
113
133
  if (this._callString && this._callerCtx && stateHash !== 'empty') base = `${base}@${this._callerCtx}`;
114
- if (!receiverType) return base;
115
- return `${base}::${hashReceiverType(receiverType)}`;
134
+ const finalKey = receiverType ? `${base}::${hashReceiverType(receiverType)}` : base;
135
+ if (!this._meta.has(finalKey)) {
136
+ this._meta.set(finalKey, {
137
+ qid, entry: entryListOf(taintedParams), stateHash,
138
+ recv: receiverType ? hashReceiverType(receiverType) : null,
139
+ });
140
+ }
141
+ return finalKey;
142
+ }
143
+
144
+ /**
145
+ * DAG-05 R05.3: restore a persisted context under its REAL key. Never writes
146
+ * the empty-entry slot on behalf of a tainted context: the key is rebuilt from
147
+ * the persisted entry list and refused unless it reproduces the persisted hash.
148
+ * Returns false (nothing stored) on any mismatch.
149
+ */
150
+ restoreContext(qid, entryList, recv, stateHash, summary) {
151
+ if (!qid || !Array.isArray(entryList) || !summary) return false;
152
+ const entry = new Set(entryList);
153
+ const h = _hashState(entry);
154
+ if (h !== stateHash) return false;
155
+ if (this._callString && h !== 'empty') return false;
156
+ const base = `${qid}::${h}`;
157
+ const key = recv ? `${base}::${recv}` : base;
158
+ this._cache.set(key, summary);
159
+ this._meta.set(key, { qid, entry: entryListOf(entry), stateHash: h, recv: recv || null });
160
+ if (h !== 'empty') {
161
+ let ctxs = this._contextsByQid.get(qid);
162
+ if (!ctxs) { ctxs = new Set(); this._contextsByQid.set(qid, ctxs); }
163
+ ctxs.add(h);
164
+ }
165
+ return true;
116
166
  }
117
167
 
118
168
  get(qid, taintedParams, receiverType) {
@@ -156,7 +206,11 @@ export class SummaryCache {
156
206
  if (!ctxs.has(stateHash) && ctxs.size >= this._maxContextsPerFn) {
157
207
  this._contextCapHits++;
158
208
  const base = this._cache.get(this._key(qid, new Set()));
159
- if (base && !base._recursive) return base;
209
+ if (base && !base._recursive) {
210
+ this._notes.push({ reason: 'context-cap-fallback', qid });
211
+ if (base._incrementalSeed) this._notes.push({ reason: 'incremental-empty-entry-reuse', qid });
212
+ return base;
213
+ }
160
214
  // No base summary yet → fall through and compute this one (we need a
161
215
  // result), but don't grow the context set.
162
216
  } else {
@@ -178,13 +232,15 @@ export class SummaryCache {
178
232
  this._cache.set(k, summary);
179
233
  if (this._hitRecursion) {
180
234
  const FP_MAX = 3;
235
+ let converged = false;
181
236
  for (let fp = 0; fp < FP_MAX; fp++) {
182
237
  if (++this._iter > this._maxIter) break;
183
238
  const prev = summary;
184
239
  summary = analyze(qid, taintedParams);
185
- if (_summaryEq(prev, summary)) break;
240
+ if (_summaryEq(prev, summary)) { converged = true; break; }
186
241
  this._cache.set(k, summary);
187
242
  }
243
+ if (!converged) this._notes.push({ reason: 'recursion-nonconverged', qid });
188
244
  }
189
245
  if (summary._recursive) delete summary._recursive;
190
246
  return summary;
@@ -224,7 +280,17 @@ export class SummaryCache {
224
280
  }
225
281
 
226
282
  size() { return this._cache.size; }
227
- clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; }
283
+ clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; this._notes = []; }
284
+
285
+ /** DAG-07 R07.3: [{reason, count, example}] over everything recorded in `_notes`, sorted by reason. */
286
+ completionNotes() {
287
+ const byReason = new Map();
288
+ for (const n of this._notes) {
289
+ const cur = byReason.get(n.reason);
290
+ if (cur) cur.count += 1; else byReason.set(n.reason, { reason: n.reason, count: 1, example: String(n.qid ?? '').slice(0, 160) });
291
+ }
292
+ return [...byReason.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1));
293
+ }
228
294
  }
229
295
 
230
296
  // Stage 3 correctness audit (detection depth): this compared mutatedParams