@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. package/src/sast/pipeline.js +34 -1
@@ -61,6 +61,7 @@
61
61
  // Cache key includes the prompt template version + model id, so any change
62
62
  // to the hardened prompt invalidates the cache.
63
63
 
64
+ import { languageClosureDigest, languagePromptExtras } from '../language/context.js';
64
65
  import * as fs from 'node:fs';
65
66
  import * as path from 'node:path';
66
67
  import * as crypto from 'node:crypto';
@@ -288,6 +289,12 @@ function fileHashOf(fileContents, file) {
288
289
  if (!file) return '';
289
290
  const c = fileContents?.[file];
290
291
  if (!c) return '';
292
+ // Haskell/Nix: a model verdict about one file depends on the modules it imports and the manifests around it, so the
293
+ // key covers that closure. Otherwise a cached `reject` would survive a change to the very module that made the
294
+ // finding true.
295
+ if (/\.(?:l?hs|hs-boot|hsc|nix)$/i.test(file)) {
296
+ try { return languageClosureDigest(fileContents, file); } catch { /* fall back to the file alone */ }
297
+ }
291
298
  return crypto.createHash('sha256').update(c).digest('hex').slice(0, 32);
292
299
  }
293
300
 
@@ -319,6 +326,10 @@ function renderPrompt(finding, fileContents, challenge, nonce, scanRoot) {
319
326
  : `${finding.file}:${finding.line} [single-point detection, no cross-file path]`;
320
327
  // Defensive: strip the delimiter literally from the untrusted excerpt so
321
328
  // an attacker can't close it early by embedding our token.
329
+ // Related files on the finding's own evidence chain (Haskell/Nix), under the same untrusted-excerpt rules.
330
+ try {
331
+ for (const x of languagePromptExtras(finding, fileContents)) context += `${context ? '\n' : ''}--- related excerpt: ${x.file} ---\n${redactPayload({ text: x.text, filePath: x.file, scanRoot }).text}`;
332
+ } catch { /* extra context is advisory */ }
322
333
  let sterileContext = String(context || '')
323
334
  .replace(/BEGIN-UNTRUSTED-CODE-EXCERPT-[a-f0-9]+/gi, '[stripped-delimiter]')
324
335
  .replace(/END-UNTRUSTED-CODE-EXCERPT-[a-f0-9]+/gi, '[stripped-delimiter]');
@@ -394,12 +405,15 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shap
394
405
  else headers['Authorization'] = `Bearer ${apiKey}`;
395
406
  }
396
407
  try {
397
- const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
408
+ // A model that accepts the connection and never answers must not hold a scan open forever: every call is bounded.
409
+ // The result is an UNAVAILABLE finding (kept, unvalidated), never a silent pass.
410
+ const ms = Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) > 0 ? Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) : 60_000;
411
+ const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(ms) });
398
412
  if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
399
413
  const j = await r.json().catch(() => null);
400
414
  return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
401
415
  } catch (e) {
402
- return { ok: false, error: e.message };
416
+ return { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : e.message };
403
417
  }
404
418
  }
405
419
 
package/src/lsp/server.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // Wire-format: vscode-jsonrpc framing (Content-Length headers). Stateless
14
14
  // per file — no incremental analysis yet.
15
15
 
16
+ import { isLanguageManifest } from '../language/discovery.js';
17
+ import { withLanguageContext, loadLanguageProject, languageOfFinding, languageFixPreview, minimalEdit } from '../language/context.js';
16
18
  import * as fs from 'node:fs';
17
19
  import * as path from 'node:path';
18
20
  import * as readline from 'node:readline';
@@ -53,6 +55,21 @@ function sevToLsp(sev) {
53
55
  }
54
56
  }
55
57
 
58
+ // The most precise location a finding carries. Haskell and Nix findings keep their ORIGINAL span (0-based columns); a
59
+ // finding with only a line is reported across that line, as before.
60
+ function _rangeOf(f) {
61
+ const o = f && f.originalLocation;
62
+ if (o && Number.isInteger(o.startLine) && Number.isInteger(o.startColumn)) {
63
+ return { start: { line: Math.max(0, o.startLine - 1), character: o.startColumn }, end: { line: Math.max(0, (Number.isInteger(o.endLine) ? o.endLine : o.startLine) - 1), character: Number.isInteger(o.endColumn) ? o.endColumn : o.startColumn + 1 } };
64
+ }
65
+ if (o && Number.isInteger(o.line) && Number.isInteger(o.column)) {
66
+ const line = Math.max(0, o.line - 1);
67
+ return { start: { line, character: o.column }, end: { line, character: 200 } };
68
+ }
69
+ const line = Math.max(0, ((f && f.line) || 1) - 1);
70
+ return { start: { line, character: 0 }, end: { line, character: 200 } };
71
+ }
72
+
56
73
  function findingToDiagnostic(f) {
57
74
  const line = Math.max(0, (f.line || 1) - 1);
58
75
  // Stage 6 correctness audit: this read f.remediation directly, but raw
@@ -63,13 +80,12 @@ function findingToDiagnostic(f) {
63
80
  // report/index.js already established for this exact split (CMP-3).
64
81
  const remediation = _remediationOf(f);
65
82
  return {
66
- range: {
67
- start: { line, character: 0 },
68
- end: { line, character: 200 },
69
- },
83
+ range: _rangeOf(f),
70
84
  severity: sevToLsp(f.severity),
71
85
  source: 'agentic-security',
72
86
  code: f.cwe || f.family || 'finding',
87
+ // carried back by the client in codeAction requests, so a fix is offered for exactly this finding
88
+ data: { id: f.id || null, stableId: f.stableId || null, rule: f.rule || null, family: f.family || null, language: f.language || languageOfFinding(f) || null },
73
89
  message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
74
90
  tags: [],
75
91
  };
@@ -101,7 +117,10 @@ const DEP_BASE_NAMES = new Set([
101
117
  'go.mod', 'Cargo.toml', 'Cargo.lock',
102
118
  'pom.xml', 'build.gradle', 'build.gradle.kts',
103
119
  ]);
104
- const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf)$/i;
120
+ const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf|cabal)$/i;
121
+ // Haskell and Nix project manifests and lock files (cabal.project*, stack.yaml*, package.yaml, flake.lock, ...) are
122
+ // recognised by the shared language discovery module, never by a second list here.
123
+ const _isLanguageDep = (base) => isLanguageManifest(base);
105
124
  const DEP_NAME_RE = /(?:openapi|swagger)\.(?:ya?ml|json)$/i;
106
125
 
107
126
  let _depCache = { rootDir: null, depFileContents: {} };
@@ -119,7 +138,7 @@ function _loadDepFileContents(rootDir) {
119
138
  if (e.isDirectory()) { walk(full); continue; }
120
139
  if (!e.isFile()) continue;
121
140
  const base = e.name;
122
- if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base)) {
141
+ if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
123
142
  let stat;
124
143
  try { stat = fs.statSync(full); } catch { continue; }
125
144
  if (stat.size > 500_000) continue;
@@ -144,8 +163,11 @@ async function scanFile(uri) {
144
163
  try {
145
164
  const rel = path.relative(_rootDir, filePath);
146
165
  const content = fs.readFileSync(filePath, 'utf8');
147
- const fileContents = { [rel]: content };
148
- const depFileContents = _loadDepFileContents(_rootDir);
166
+ let fileContents = { [rel]: content };
167
+ let depFileContents = _loadDepFileContents(_rootDir);
168
+ // Haskell/Nix: scan the saved file together with the modules it imports and the ones that import it (bounded).
169
+ const lc = withLanguageContext(_rootDir, fileContents, depFileContents);
170
+ fileContents = lc.fileContents; depFileContents = lc.depFileContents;
149
171
  // Premortem 4R-12 + 4R-15: reset the per-process custom-rules budget at
150
172
  // the start of each LSP scan. Each save is a logical scan session; without
151
173
  // the reset, a long-lived LSP server would accumulate budget across saves
@@ -220,12 +242,50 @@ function handleInitialize(params) {
220
242
  save: { includeText: false },
221
243
  },
222
244
  diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false },
245
+ // quick fixes for Haskell and Nix findings (preview edits from the verified language fixers)
246
+ codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: false },
223
247
  },
224
248
  serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
225
249
  };
226
250
  }
227
251
 
252
+ /**
253
+ * textDocument/codeAction: a quick fix for each agentic-security diagnostic in range that a language fixer can repair.
254
+ * The edit is a PREVIEW the editor applies; nothing is written here. A finding with no safe fix gets a disabled
255
+ * action that says why, never a silent omission.
256
+ */
257
+ async function codeActionsFor(params) {
258
+ const uri = params && params.textDocument && params.textDocument.uri;
259
+ const diags = ((params && params.context && params.context.diagnostics) || []).filter((d) => d && d.source === 'agentic-security' && d.data && d.data.id);
260
+ const stored = (uri && _diagnosticsByUri.get(uri)) || [];
261
+ if (!diags.length || !stored.length) return [];
262
+ let files = null;
263
+ const actions = [];
264
+ for (const d of diags) {
265
+ const f = stored.find((x) => x && x.id === d.data.id);
266
+ if (!f || !languageOfFinding(f)) continue;
267
+ if (!files) files = loadLanguageProject(_rootDir).files;
268
+ let prev;
269
+ try { prev = await languageFixPreview(f, files); } catch (e) { prev = { ok: false, reason: String((e && e.message) || e) }; }
270
+ if (prev && prev.ok && prev.file) {
271
+ const target = pathToUri(path.join(_rootDir, prev.file));
272
+ actions.push({
273
+ title: `Fix${prev.label ? ` (${prev.label})` : ''}: ${f.vuln || f.rule || 'finding'}`,
274
+ kind: 'quickfix', diagnostics: [d], isPreferred: false,
275
+ edit: { changes: { [target]: [minimalEdit(prev.before, prev.after)] } },
276
+ data: { stableId: f.stableId || null, verified: { syntax: true, rescan: true } },
277
+ });
278
+ } else {
279
+ actions.push({ title: `No automatic fix: ${f.vuln || f.rule || 'finding'}`, kind: 'quickfix', diagnostics: [d], disabled: { reason: (prev && (prev.reason || prev.status)) || 'no deterministic fix exists for this finding' } });
280
+ }
281
+ }
282
+ return actions;
283
+ }
284
+
228
285
  async function handleMessage(msg) {
286
+ if (msg.method === 'textDocument/codeAction') {
287
+ return { id: msg.id, result: await codeActionsFor(msg.params || {}) };
288
+ }
229
289
  if (msg.method === 'initialize') {
230
290
  return { id: msg.id, result: handleInitialize(msg.params || {}) };
231
291
  }
@@ -254,7 +314,7 @@ async function handleMessage(msg) {
254
314
  const savedPath = uriToPath(uri);
255
315
  if (savedPath && _depCache.rootDir === _rootDir) {
256
316
  const base = path.basename(savedPath);
257
- if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base)) {
317
+ if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
258
318
  try {
259
319
  const rel = path.relative(_rootDir, savedPath);
260
320
  const st = fs.statSync(savedPath);
package/src/mcp/CLAUDE.md CHANGED
@@ -65,3 +65,10 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
65
65
  - **Lifecycle.** `_codeFingerprint()` reads source files at module-load time. New files added to the MCP source set won't be in the fingerprint until they're added to the `files = […]` array in `server.js`.
66
66
  - **Audit log.** The chain hashes plain JSON lines; a full-file rewrite is not detectable without a remote sink. Acknowledged limitation.
67
67
  - **Concurrency.** `stdio.js`'s `'data'` handler is async; concurrent `apply_fix` calls can race on `fix-history/`. Today benign because fixed-fix-history is idempotent on retry, but a future stateful tool needs serialization.
68
+
69
+ ## Haskell and Nix context
70
+
71
+ `scan_diff`, `verify_fix` and `synthesize_fix` add the import closure of a Haskell or Nix file and the manifests as context
72
+ (`language/context.js` `withLanguageContext`, bounded in files and bytes, read-only) and still report only the files asked
73
+ about. The tool that previews a fix for these files uses the language fixers; `apply_fix` is unchanged (its generic confined-write
74
+ path), and the language-aware apply with backup and `undo` is the CLI's `fix --apply`.
package/src/mcp/tools.js CHANGED
@@ -371,6 +371,10 @@ export const scan_diff = {
371
371
  const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));
372
372
 
373
373
  const fileContents = {};
374
+ // `_confine` returns real paths; the session root may itself be a symlink (macOS /tmp, /var), so keys are made
375
+ // relative to the REAL root. A key built from the unresolved root climbs out of the tree and no finding ever
376
+ // matches the file it was asked about.
377
+ const rootReal = fs.realpathSync(path.resolve(sessionRoot));
374
378
  let totalBytes = 0;
375
379
  for (const a of abs) {
376
380
  let stat;
@@ -383,7 +387,7 @@ export const scan_diff = {
383
387
  }
384
388
  let content;
385
389
  try { content = fs.readFileSync(a, 'utf8'); } catch { continue; }
386
- const rel = path.relative(sessionRoot, a).replace(/\\/g, '/');
390
+ const rel = path.relative(rootReal, a).replace(/\\/g, '/');
387
391
  fileContents[rel] = content;
388
392
  }
389
393
 
@@ -403,9 +407,13 @@ export const scan_diff = {
403
407
  // user's real project on every pre-write self-correction scan. Confirmed
404
408
  // by direct execution before this fix (11 state artifacts written by a
405
409
  // single scan_diff-shaped call).
406
- const result = await withStateWritesDisabled(() =>
407
- runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
410
+ // Haskell and Nix results depend on imported modules and manifests, so the files asked about are scanned together
411
+ // with their import closure (read from disk, bounded). Findings are still reported only for the requested files.
408
412
  const wantSet = new Set(Object.keys(fileContents));
413
+ const { withLanguageContext } = await import('../language/context.js');
414
+ const lc = withLanguageContext(sessionRoot, fileContents, {});
415
+ const result = await withStateWritesDisabled(() =>
416
+ runScan(sessionRoot, { network: false, fileContents: lc.fileContents, depFileContents: lc.depFileContents, deep: true, deepInCi: true }));
409
417
  const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
410
418
  const min = sevRank[severity] ?? 0;
411
419
  // Stage 6 correctness audit (historical): this used to only read
@@ -1055,12 +1063,26 @@ export const synthesize_fix = {
1055
1063
  // even a mis-attributed swap can't land a bad edit. No stored replacement,
1056
1064
  // no per-finding bloat in last-scan.json.
1057
1065
  let autofix = null;
1066
+ let languageFix = null;
1058
1067
  if (!hasReplacement) {
1059
1068
  try {
1060
1069
  const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
1061
1070
  const det = synthesizeDeterministicPatch(f, fs.readFileSync(abs, 'utf8'));
1062
1071
  if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
1063
1072
  } catch { /* best-effort — no file / no rule → no autofix */ }
1073
+ // Haskell and Nix: a read-only preview from the language fixer, with the verification gates' verdict and the
1074
+ // FULL / MITIGATION / WORKAROUND tier. It writes nothing; apply_fix re-verifies before anything lands.
1075
+ if (!autofix) {
1076
+ try {
1077
+ const lc = await import('../language/context.js');
1078
+ if (lc.languageOfFinding(f)) {
1079
+ const proj = lc.loadLanguageProject(ctx.sessionRoot);
1080
+ const prev = await lc.languageFixPreview(f, proj.files);
1081
+ 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 || [] };
1082
+ if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
1083
+ }
1084
+ } catch { /* best-effort: the preview is advisory */ }
1085
+ }
1064
1086
  }
1065
1087
  // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
1066
1088
  // not a finding excerpt. Running it through redactString silently corrupts
@@ -1079,6 +1101,7 @@ export const synthesize_fix = {
1079
1101
  replacement: hasReplacement ? fix.replacement : null,
1080
1102
  template: fix.code || null,
1081
1103
  autofix,
1104
+ languageFix,
1082
1105
  // #15 — the regression test the scan annotator already generated for this
1083
1106
  // finding (present when a PoC was built). Surfaced here so the fix flow
1084
1107
  // writes the test alongside the patch; fix-verify-loop then runs it, so an
Binary file
@@ -42,7 +42,7 @@ export const SCAN_HEALTH_SCHEMA_VERSION = 1;
42
42
  * never a fabricated all-zero summary.
43
43
  * @returns {object} scanHealth per PRD §10.3, additive fields only.
44
44
  */
45
- export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null } = {}) {
45
+ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null, languageCoverage = null, supplyChain = null } = {}) {
46
46
  const conditions = [];
47
47
  const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
48
48
  const filesTimedOut = Number(scanMeta?.filesTimedOut) || 0;
@@ -84,11 +84,29 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
84
84
  conditions.push(`${analyzerCoverage.failed} analyzer(s) threw on at least one file`);
85
85
  }
86
86
 
87
+ // CORE-003: Haskell/Nix coverage outcomes (missing grammar, adapter
88
+ // exception, timeout, unresolved branch) from language/contracts.js.
89
+ // Additive: absent input leaves the output shape and conditions untouched.
90
+ if (languageCoverage && Array.isArray(languageCoverage.conditions)) {
91
+ conditions.push(...languageCoverage.conditions);
92
+ }
93
+
94
+ // NIX-005: build-trust findings ride in the ordinary supplyChain bucket. A finding that
95
+ // rests on an unresolved branch or a partial module graph is an analysis gap, so it is
96
+ // surfaced here rather than counted as a settled result.
97
+ const trustFindings = Array.isArray(supplyChain) ? supplyChain.filter((f) => f && f.type === 'nix_build_trust') : [];
98
+ const trustUncertain = trustFindings.filter((f) => Array.isArray(f.uncertainty) && f.uncertainty.length > 0).length;
99
+ if (trustUncertain > 0) {
100
+ conditions.push(`${trustUncertain} Nix build-trust finding(s) rest on an unresolved branch or partial module graph`);
101
+ }
102
+
87
103
  const status = conditions.length > 0 ? 'partial' : 'complete';
88
104
 
89
105
  return {
90
106
  schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
91
107
  status,
108
+ ...(trustFindings.length ? { buildTrust: { findings: trustFindings.length, uncertain: trustUncertain } } : {}),
109
+ ...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}) } } : {}),
92
110
  files: {
93
111
  expected: scanMeta?.checkpoint?.total ?? null,
94
112
  scanned: scanMeta?.filesScanned ?? null,
@@ -205,6 +205,23 @@ export function buildScorecard(inputs) {
205
205
  corpusBaseline: committed.corpusBaseline
206
206
  ? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
207
207
  : null,
208
+ // Haskell and Nix support (HASKELL_NIXOS_FULL_CAPABILITY_PRD.md DOC-002): the committed support registry, passed through with
209
+ // its denominators, layers, corpus revision, date and tool availability. Read, never re-measured here.
210
+ languageSupport: committed.languageSupport
211
+ ? {
212
+ source: 'docs/language-support.json',
213
+ generatedAt: committed.languageSupport.generatedAt || null,
214
+ node: committed.languageSupport.node || null,
215
+ tools: committed.languageSupport.tools || null,
216
+ targets: committed.languageSupport.targets || null,
217
+ limits: committed.languageSupport.limits || [],
218
+ languages: Object.fromEntries(Object.entries(committed.languageSupport.languages || {}).map(([k, v]) => [k, {
219
+ frozen: v.frozen || null, measuredOn: v.measuredOn || null,
220
+ rows: Object.values(v.rows || {}).map((r) => ({ capability: r.capability, status: r.status, reasons: r.reasons || [], evidence: r.evidence ? { metric: r.evidence.metric || null, layer: r.evidence.layer || null, tp: r.evidence.tp ?? null, fp: r.evidence.fp ?? null, fn: r.evidence.fn ?? null, tn: r.evidence.tn ?? null, precision: r.evidence.precision ?? null, recall: r.evidence.recall ?? null, f1: r.evidence.f1 ?? null, cases: r.evidence.cases ?? null, familyCount: r.evidence.families ? Object.keys(r.evidence.families).length : null } : null })),
221
+ unknownOutcomes: v.unknownOutcomes || null, metamorphic: v.metamorphic || null,
222
+ }])),
223
+ }
224
+ : null,
208
225
  independent: committed.independent
209
226
  ? {
210
227
  source: 'bench/independent/RESULT.json',
@@ -621,6 +638,40 @@ export function renderScorecardMarkdown(m) {
621
638
  }
622
639
  }
623
640
 
641
+ const ls = m.committedInputs.languageSupport;
642
+ if (ls) {
643
+ L.push('## Haskell and Nix/NixOS support');
644
+ L.push('');
645
+ L.push(`*Committed artifact:* \`${ls.source}\`, measured ${ls.generatedAt || 'unknown date'} on node ${ls.node || '?'}. It is read here, not re-measured by this command.`);
646
+ L.push('');
647
+ L.push('Every row is **supported** only from passing evidence of its own metric kind. Layers are scored independently (the taint layer');
648
+ L.push('is the injection families, the SAST layer is every other family) and a finding of a different family inside a case is counted');
649
+ L.push('separately (family-scoped scoring); the strict precision that counts it is stored in the registry. Denominators are cases, with');
650
+ L.push('TP/FP/FN/TN shown; a row without a denominator carries a different kind of evidence (parse counts, fix gates, manifests).');
651
+ L.push('');
652
+ for (const [lang, v] of Object.entries(ls.languages)) {
653
+ L.push(`### ${lang === 'haskell' ? 'Haskell' : 'Nix and NixOS'}`);
654
+ L.push('');
655
+ L.push('| Capability | Status | Layer | Cases | TP | FP | FN | TN | Precision | Recall | F1 | Families |');
656
+ L.push('| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
657
+ const pct = (x) => (typeof x === 'number' ? `${(x * 100).toFixed(1)}%` : ', ');
658
+ for (const r of v.rows) {
659
+ const e = r.evidence;
660
+ L.push(`| ${r.capability} | ${r.status} | ${e && e.layer ? e.layer : '—'} | ${e && e.cases != null ? e.cases : '—'} | ${e && e.tp != null ? e.tp : '—'} | ${e && e.fp != null ? e.fp : '—'} | ${e && e.fn != null ? e.fn : '—'} | ${e && e.tn != null ? e.tn : '—'} | ${e ? pct(e.precision) : '—'} | ${e ? pct(e.recall) : '—'} | ${e ? pct(e.f1) : '—'} | ${e && e.familyCount != null ? e.familyCount : '—'} |`);
661
+ }
662
+ L.push('');
663
+ const blocked = v.rows.filter((r) => r.status !== 'supported');
664
+ if (blocked.length) { L.push(`Not supported here: ${blocked.map((r) => `${r.capability} (${r.status}: ${(r.reasons[0] || '').slice(0, 140)})`).join('; ')}.`); L.push(''); }
665
+ if (v.unknownOutcomes) L.push(`Unknown and unmodelled cases: ${JSON.stringify(v.unknownOutcomes)}.`);
666
+ if (v.metamorphic) L.push(`Metamorphic pairs: ${JSON.stringify(v.metamorphic)}.`);
667
+ L.push('');
668
+ }
669
+ if (ls.tools) L.push(`Tools present where this was measured: ${Object.entries(ls.tools).map(([k, x]) => `${k}=${x === false ? 'absent' : x}`).join(', ')}.`);
670
+ L.push('');
671
+ for (const lim of ls.limits) L.push(`- ${lim}`);
672
+ L.push('');
673
+ }
674
+
624
675
  const ind = m.committedInputs.independent;
625
676
  if (ind && ind.overall) {
626
677
  L.push('## Independent evaluation population — the number that matters');
@@ -20,6 +20,7 @@
20
20
  // scan.components. Extraction precision is verified by a smoke test against
21
21
  // a labelled fixture set.
22
22
 
23
+ import { extractLanguageAI } from '../language/aibom.js';
23
24
  import * as crypto from 'node:crypto';
24
25
  import { statePath, safeWriteState } from './state-dir.js';
25
26
 
@@ -199,6 +200,16 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
199
200
  if (classes.includes('vector-store')) vectorStores.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
200
201
  if (classes.includes('embedding-provider')) embeddings.push({ ecosystem: c.ecosystem, name: c.name, version: c.version });
201
202
  }
203
+ // 4. Haskell and Nix (X-002): declared / installed / enabled / invoked are kept apart, evidence is a list,
204
+ // and anything source does not spell out is `unresolved` rather than guessed.
205
+ let lang = { models: [], endpoints: [], services: [], frameworks: [], vectorStores: [], embeddings: [], promptTemplates: [], unresolved: [], links: [], gaps: [] };
206
+ try { lang = extractLanguageAI({ ...(meta.manifests || {}), ...(fileContents || {}) }); } catch { /* best effort: the JS/Python inventory above stands */ }
207
+ const knownModel = new Set(models.map((m) => `${m.provider}:${m.modelId}`));
208
+ for (const m of lang.models) { const k = `${m.provider}:${m.modelId}`; if (!knownModel.has(k)) { knownModel.add(k); models.push({ ...m, file: (m.evidence && m.evidence[0] && m.evidence[0].file) || null, line: (m.evidence && m.evidence[0] && m.evidence[0].line) || null }); } }
209
+ promptTemplates.push(...lang.promptTemplates);
210
+ const knownFw = new Set(frameworks.map((f) => `${f.ecosystem}:${f.name}`));
211
+ for (const f of lang.frameworks) { const k = `${f.ecosystem}:${f.name}`; if (!knownFw.has(k)) { knownFw.add(k); frameworks.push(f); } }
212
+ vectorStores.push(...lang.vectorStores); embeddings.push(...lang.embeddings);
202
213
  return {
203
214
  aibomFormat: 'agentic-security AI-BOM',
204
215
  version: '1',
@@ -221,6 +232,15 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
221
232
  frameworks,
222
233
  vectorStores,
223
234
  embeddings,
235
+ endpoints: lang.endpoints,
236
+ services: lang.services,
237
+ unresolved: lang.unresolved,
238
+ links: lang.links,
239
+ limits: [
240
+ 'an AI component being declared, installed or enabled does not show that a model is called or that data is sent to it',
241
+ 'models, endpoints and providers that source does not spell out are listed under `unresolved`, not guessed',
242
+ ...(lang.gaps || []).map((g) => g.detail || g.kind),
243
+ ],
224
244
  summary: {
225
245
  totalModels: models.length,
226
246
  totalProviders: new Set(models.map(m => m.provider)).size,
@@ -229,6 +249,10 @@ export function buildAIBOM(scan, fileContents = {}, meta = {}) {
229
249
  promptTemplates: promptTemplates.length,
230
250
  frameworks: frameworks.length,
231
251
  vectorStores: vectorStores.length,
252
+ endpoints: lang.endpoints.length,
253
+ services: lang.services.length,
254
+ embeddings: embeddings.length,
255
+ unresolved: lang.unresolved.length,
232
256
  },
233
257
  };
234
258
  }
@@ -297,6 +321,30 @@ export function aibomToMarkdown(aibom) {
297
321
  out.push('');
298
322
  }
299
323
 
324
+ const row = (cells) => `| ${cells.map((c) => String(c ?? '—').replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ')} |`;
325
+ if ((aibom.endpoints || []).length) {
326
+ out.push('## Endpoints'); out.push(''); out.push('| Provider | Purpose | URL (redacted) | Evidence |'); out.push('|---|---|---|---|');
327
+ for (const e of aibom.endpoints) out.push(row([e.provider, e.purpose, e.url, (e.evidence || []).map((x) => `${x.file}:${x.line}`).join(', ')]));
328
+ out.push('');
329
+ }
330
+ if ((aibom.services || []).length) {
331
+ out.push('## Services'); out.push(''); out.push('| Service | Kind | Status | Evidence |'); out.push('|---|---|---|---|');
332
+ for (const x of aibom.services) out.push(row([x.name, x.kind, x.status, (x.evidence || []).map((e) => `${e.file}:${e.line}`).join(', ')]));
333
+ out.push('');
334
+ }
335
+ if ((aibom.embeddings || []).length) {
336
+ out.push('## Embeddings'); out.push(''); out.push('| Name | Provider | Status |'); out.push('|---|---|---|');
337
+ for (const e of aibom.embeddings) out.push(row([e.name, e.provider, e.status]));
338
+ out.push('');
339
+ }
340
+ if ((aibom.unresolved || []).length) {
341
+ out.push('## Unresolved'); out.push(''); out.push('| Kind | Location | Reason |'); out.push('|---|---|---|');
342
+ for (const u of aibom.unresolved) out.push(row([u.kind, `${u.file}:${u.line}`, u.reason]));
343
+ out.push('');
344
+ }
345
+ if ((aibom.limits || []).length) { out.push('## Limits'); out.push(''); for (const l of aibom.limits) out.push(`- ${l}`); out.push(''); }
346
+ out.push('> This document is the proprietary agentic-security AI-BOM, not a CycloneDX document. A CycloneDX 1.6 ML-BOM view is available separately.');
347
+ out.push('');
300
348
  return out.join('\n');
301
349
  }
302
350
 
@@ -324,9 +372,9 @@ export function toCycloneDXMLBOM(aibom, meta = {}) {
324
372
  const models = (aibom && aibom.models) || [];
325
373
  const components = models.map((m) => ({
326
374
  type: 'machine-learning-model',
327
- 'bom-ref': `model:${m.provider || 'unknown'}/${m.name || 'unknown'}${m.version ? `@${m.version}` : ''}`,
328
- name: m.name || 'unknown',
329
- ...(m.version ? { version: m.version } : {}),
375
+ 'bom-ref': `model:${m.provider || 'unknown'}/${m.name || m.modelId || 'unknown'}${m.version || m.revision ? `@${m.version || m.revision}` : ''}`,
376
+ name: m.name || m.modelId || 'unknown',
377
+ ...(m.version || m.revision ? { version: m.version || m.revision } : {}),
330
378
  modelCard: {
331
379
  modelParameters: {
332
380
  ...(m.provider ? { approach: { type: 'supervised' } } : {}),
@@ -359,6 +407,10 @@ export function toCycloneDXMLBOM(aibom, meta = {}) {
359
407
  component: { type: 'application', name: 'scan-target', version: '1.0.0' },
360
408
  },
361
409
  components,
410
+ ...(((aibom && aibom.services) || []).length || ((aibom && aibom.endpoints) || []).length ? { services: [
411
+ ...(aibom.services || []).map((x) => ({ 'bom-ref': `service:${x.name}`, name: x.name, provider: { name: x.provider || 'unknown' }, description: `${x.kind} (${x.status})`, properties: [{ name: 'agentic-security:status', value: String(x.status) }, { name: 'agentic-security:egress', value: 'not-established' }] })),
412
+ ...(aibom.endpoints || []).map((e) => ({ 'bom-ref': `endpoint:${e.url}`, name: `${e.provider} ${e.purpose}`, provider: { name: e.provider }, endpoints: [e.url], properties: [{ name: 'agentic-security:status', value: String(e.status) }] })),
413
+ ] } : {}),
362
414
  };
363
415
  }
364
416
 
@@ -394,6 +446,12 @@ export function validateMLBOM(doc) {
394
446
  `components[${i}] is a machine-learning-model but carries no modelCard — that is the whole ML-BOM extension`);
395
447
  }
396
448
  }
449
+ for (const [i, sv] of (Array.isArray(doc.services) ? doc.services : []).entries()) {
450
+ req(typeof sv.name === 'string' && sv.name, `services[${i}].name is required`);
451
+ if (sv.endpoints !== undefined) req(Array.isArray(sv.endpoints) && sv.endpoints.every((u) => typeof u === 'string' && !/\/\/[^/@]*:[^/@]*@/.test(u)), `services[${i}].endpoints must be URL strings without credentials`);
452
+ }
453
+ const refs = (Array.isArray(doc.components) ? doc.components : []).concat(Array.isArray(doc.services) ? doc.services : []).map((c) => c && c['bom-ref']).filter(Boolean);
454
+ req(new Set(refs).size === refs.length, 'bom-ref values must be unique');
397
455
  return { ok: errors.length === 0, errors, checked: 'structural (required fields + ML-BOM component shape), NOT full JSON-Schema validation' };
398
456
  }
399
457
 
@@ -95,6 +95,16 @@ export const ARTIFACT_REGISTRY = [
95
95
  // clears by default, same as last-scan.json itself.
96
96
  { name: 'lineage-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
97
97
  { name: 'lineage-graph.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
98
+ // X-016: Haskell/Nix analysis metadata (capability status, parser/resolver ledger, limitations, input digests) and the
99
+ // language BOM, written by src/language/state-artifacts.js beside last-scan.json. Paths and digests only, never source
100
+ // text. Scan-class retention for the metadata; the BOM is evidence.
101
+ { name: 'language-analysis.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
102
+ { name: 'language-analysis.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
103
+ { name: 'language-bom.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
104
+ { name: 'language-bom.json.sig', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'src/language/state-artifacts.js (persistLanguageArtifacts)' },
105
+ // X-016: pre-image of every file a Haskell/Nix fix rewrote (original text + meta), the undo source. It holds customer
106
+ // source, so it is a backup-class artifact and marked confidential for the encryption policy.
107
+ { name: 'fix-backups', kind: 'dir', classification: 'generated', retentionClass: 'backup', confidential: true, source: 'src/language/fix-lifecycle.js (writeWithBackup)' },
98
108
  // M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): the "Data-Flow
99
109
  // Time Machine" foundation — one commit-keyed GraphSnapshot per scan,
100
110
  // written by src/lineage/graph-snapshot.js's persistGraphSnapshot(),
@@ -237,6 +247,8 @@ export const ARTIFACT_REGISTRY = [
237
247
  { name: 'provenance-providers.yml', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M3 §3.4 GitHub/GitLab provider enrichment opt-in ({token} or provider-scoped tokens) — read by posture/provenance/providers/config.js, never written by the scanner; env vars (AGENTIC_SECURITY_GITHUB_TOKEN/AGENTIC_SECURITY_GITLAB_TOKEN) take precedence when set' },
238
248
  { name: 'repo-lineage.json', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M4 §4.2 cross-repository lineage declaration ({linkedFrom: {path, atCommit}}) — read by posture/provenance/repo-lineage.js, never written by the scanner; the linked path is verified as a real local git repo before use, no remote fetch' },
239
249
  { name: 'recipient-profiles.json', kind: 'file', classification: 'operator-config', note: 'FR-506 recipient/subprocessor governance profiles — hand- or agent-authored via `governance propose-edit`, never scanner-regenerable, so a routine reset must never delete it' },
250
+ { name: 'hackage-advisories.json', kind: 'file', classification: 'operator-config', note: 'X-016: operator-supplied Hackage advisory snapshot read by language/haskell-supply.js; never written by the scanner, so `reset` preserves it' },
251
+ { name: 'nix-advisories.json', kind: 'file', classification: 'operator-config', note: 'QA-005: operator-supplied Nix advisory snapshot read by language/resolved-pass.js; never written by the scanner, so `reset` preserves it' },
240
252
  { name: 'cross-repo-links.json', kind: 'file', classification: 'operator-config', note: 'M5 deliverable #8 (FR-304 "declared" half) — declared local<->remote node links between two independently-scanned repos, written via `federate declare --yes`, never scanner-regenerable, so a routine reset must never delete it' },
241
253
  { name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
242
254
  { name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
@@ -43,11 +43,13 @@
43
43
  // attestation" describes independent certification, not attestation).
44
44
 
45
45
  import * as fs from 'node:fs';
46
+ import * as crypto from 'node:crypto';
46
47
  import * as path from 'node:path';
47
48
 
48
49
  import { statePath, stateWritesEnabled } from './state-dir.js';
49
50
  import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
50
51
  import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
52
+ import { LANGUAGE_COMPLIANCE_FAMILIES, languageAnalysisGaps } from '../language/compliance-map.js';
51
53
  import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
52
54
  // FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
53
55
  // metadata. renderWalkthrough()'s output is console.log'd verbatim by
@@ -497,6 +499,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
497
499
  || logicVulns.length > 0
498
500
  || supplyChain.length > 0;
499
501
 
502
+ const langIncomplete = languageAnalysisGaps(scan);
500
503
  const results = [];
501
504
  for (const c of fw.controls || []) {
502
505
  const obs = [];
@@ -599,6 +602,12 @@ export function evaluateFramework(scanRoot, fw, scan) {
599
602
  allCleared = false;
600
603
  contributingFindings.push(...open);
601
604
  obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
605
+ } else if (langIncomplete.incomplete && LANGUAGE_COMPLIANCE_FAMILIES.has(fam)) {
606
+ // Empty bucket, but Haskell/Nix analysis in this scan did not complete (a parser failed, a required analyzer
607
+ // was off, an effective configuration is partial): an empty bucket is then not evidence, and an unknown
608
+ // option or an unresolved taint result must never read as satisfied.
609
+ obs.push(`⚠ ${fam}: Haskell/Nix analysis was incomplete (${langIncomplete.reasons[0]}${langIncomplete.reasons.length > 1 ? `; +${langIncomplete.reasons.length - 1} more` : ''}), so an empty finding bucket is not evidence (not assessed).`);
610
+ hasUnverifiableMapping = true;
602
611
  } else if (!assessable) {
603
612
  // Empty bucket, but the scan examined nothing — see the guard above.
604
613
  // Not evidence, and deliberately not a failure either.
@@ -855,4 +864,36 @@ export function persistWalkthrough(scanRoot, fw, body) {
855
864
  return fp;
856
865
  }
857
866
 
867
+ /**
868
+ * A signable evidence document for one framework evaluation (the same shape `signComplianceEvidence` covers), so a
869
+ * walkthrough can be handed to an auditor with an integrity signature. Statuses, observations and finding references
870
+ * are copied from the evaluation; nothing is added. The language-analysis gaps that capped any control travel in
871
+ * `provenance`, so a reader of the signed document sees why a control is "partial".
872
+ */
873
+ export function walkthroughToEvidenceJsonLd(fw, evaluation, { scan = null, engineVersion = null, generatedAt = null } = {}) {
874
+ const controls = (evaluation || []).map((e) => ({
875
+ '@type': 'Control', id: e.control && e.control.id, title: e.control && e.control.summary, status: e.status,
876
+ observations: e.observations || [], controlRefs: e.controlRefs || [],
877
+ codeTestable: (e.control && e.control.codeTestable) || null,
878
+ }));
879
+ const count = (st) => controls.filter((c) => c.status === st).length;
880
+ const gaps = languageAnalysisGaps(scan);
881
+ const digest = crypto.createHash('sha256').update(JSON.stringify(controls)).digest('hex');
882
+ return {
883
+ '@context': { '@vocab': 'https://agentic-security.io/compliance/v1/', schema: 'https://schema.org/' },
884
+ '@type': 'ComplianceEvidence',
885
+ schemaVersion: 1,
886
+ statusSemantics: 'present = no open findings on every mapped family and nothing capped it; partial = evidence with a gap or an unverifiable mapping; absent = nothing checked out; manual = no automated mapping',
887
+ policySource: `bundled:${fw.id}`,
888
+ framework: fw.id,
889
+ version: fw.controlsDigest || null,
890
+ generatedAt: generatedAt || new Date().toISOString(),
891
+ disclaimer: EVIDENCE_GRADE_DISCLAIMER_SHORT,
892
+ provenance: { engineVersion, languageAnalysis: { present: gaps.present, incomplete: gaps.incomplete, reasons: gaps.reasons } },
893
+ evidenceDigest: digest,
894
+ summary: { total: controls.length, present: count('present'), partial: count('partial'), absent: count('absent'), manual: count('manual') },
895
+ controls,
896
+ };
897
+ }
898
+
858
899
  export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };
@@ -38,8 +38,8 @@ function sinkKey(f) {
38
38
  // finding reaching this point has a distinct line — folding the line into
39
39
  // the key here can only split buckets more finely, never wrongly merge one.
40
40
  const sinkExpr = (parser === 'IR-TAINT'
41
- ? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}`
42
- : (f.sink?.label || f.sink?.snippet || f.snippet || ''))
41
+ ? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}${f.siteKey ? `:${f.siteKey}` : ''}` // Haskell: distinct sinks on one line stay distinct
42
+ : `${f.sink?.label || f.sink?.snippet || f.snippet || ''}${f.siteKey ? `|${f.siteKey}` : ''}`)
43
43
  .replace(/['"`][^'"`]*['"`]/g, '_S_')
44
44
  .replace(/\s+/g, ' ')
45
45
  .trim()
@@ -197,6 +197,12 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
197
197
  cwe: finding.cwe ?? null,
198
198
  family: finding.family ?? null,
199
199
  parser: finding.parser ?? null,
200
+ // Haskell/Nix findings carry where the code is and what kind of evidence decided them. Added only when present, so
201
+ // the canonical bytes (and signatures) of every existing bundle are unchanged.
202
+ ...(finding.language ? { language: finding.language } : {}),
203
+ ...(finding.evidenceKind ? { evidenceKind: finding.evidenceKind } : {}),
204
+ ...(finding.originalLocation ? { originalLocation: finding.originalLocation } : {}),
205
+ ...(finding.generatedLocation ? { generatedLocation: finding.generatedLocation } : {}),
200
206
  },
201
207
  evidence: {
202
208
  // The honest chain, in the vocabulary the engine already uses.
@@ -206,7 +212,7 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
206
212
  exploitability: finding.exploitability ?? null,
207
213
  unreachable: finding.unreachable ?? null,
208
214
  // Present only for the layers that produce them; absent means absent.
209
- taintPath: finding.pathSteps ?? finding.exampleFlows ?? null,
215
+ taintPath: finding.pathSteps ?? finding.exampleFlows ?? (Array.isArray(finding.chain) && finding.language ? finding.chain : null),
210
216
  discovery: finding.discovery ?? null,
211
217
  },
212
218
  engine: {