@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 (134) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +17 -2
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +125 -14
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,198 @@
1
+ // Nix configuration lineage (X-004 / X-005). Contributes nodes, data elements, edges, flows and evidence to an
2
+ // EXISTING DataFlowGraph v1; it never forks the schema. The inputs are the NIX-006 secret-placement findings
3
+ // (which already include secrets interpolated into generated scripts), so a credential-bearing option is followed
4
+ // to where the Nix evaluation puts it: the world-readable store, a derivation environment, a log, or the source
5
+ // file itself.
6
+ //
7
+ // Scope and honesty:
8
+ // * Every contribution is STATIC CONFIGURATION evidence. A node carries `analysis: {language: 'nix',
9
+ // kind: 'configuration', scope: 'static-configuration', runtime: false}`: a declared service or a store
10
+ // path is not proof that a value is exposed at runtime, and nothing here claims it is.
11
+ // * A runtime reference (a `…File` option, EnvironmentFile, sops/agenix path) is not a flow; the secret analyzer
12
+ // already treats it as safe and so does this view.
13
+ // * The original location is the Nix source; a generated-script location is kept as `generatedLocation` when the
14
+ // value lands in a generated shell script. Neither is invented.
15
+ // * No application-level link is inferred: a Nix store file does not become an application read by name.
16
+
17
+ import * as ids from './ids.js';
18
+ import { classifyDataElementName } from './classification.js';
19
+ import { analyzeNixSecrets } from '../language/nix-secrets.js';
20
+ import { analyzeNixPersonalData, destinationOf } from '../language/nix-privacy.js';
21
+
22
+ export const NIX_VIEW_VERSION = 'nix-lineage/1';
23
+
24
+ const ANALYSIS = Object.freeze({ language: 'nix', kind: 'configuration', scope: 'static-configuration', runtime: false });
25
+
26
+ // exposure -> sink decision. Each is a registry-shaped decision (kind + subtype), never a call site.
27
+ const SINKS = {
28
+ store: { kind: 'store', subtype: 'file', label: 'Nix store (world-readable)', stage: 'storage' },
29
+ build: { kind: 'store', subtype: 'file', label: 'Derivation environment (recorded in the .drv)', stage: 'storage' },
30
+ log: { kind: 'log', subtype: 'log', label: 'Evaluation or script log output', stage: 'storage' },
31
+ 'plaintext-source': { kind: 'store', subtype: 'file', label: 'Configuration source file', stage: 'storage' },
32
+ };
33
+
34
+ const lastSegment = (detail) => String(detail || '').replace(/^option\s+/, '').replace(/^config\./, '').split('.').filter(Boolean).pop() || 'secret';
35
+
36
+ /**
37
+ * Adds Nix configuration lineage to `graph` in place. Returns `{added, nixFiles, gaps}`.
38
+ * @param {object} graph a DataFlowGraph v1 (mutated)
39
+ * @param {Record<string,string>} files every file handed to the scan
40
+ * @param {{repository?: string}} opts
41
+ */
42
+ export function contributeNixConfigLineage(graph, files, opts = {}) {
43
+ const nixFiles = Object.keys(files || {}).filter((f) => /\.nix$/i.test(f));
44
+ const result = { added: { nodes: 0, edges: 0, flows: 0, dataElements: 0 }, nixFiles: nixFiles.length, gaps: [] };
45
+ if (!nixFiles.length) return result;
46
+ const repository = opts.repository || '';
47
+ let analysis;
48
+ try { analysis = analyzeNixSecrets({ files }); } catch (e) { result.gaps.push(`nix secret analysis failed: ${String((e && e.message) || e)}`); return result; }
49
+ result.gaps.push(...(analysis.gaps || []).map((g) => (typeof g === 'string' ? g : (g && (g.reason || g.message)) || 'unresolved Nix construct')));
50
+
51
+ const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
52
+ const els = new Map(graph.dataElements.map((d) => [d.id, d]));
53
+ const edges = new Map(graph.edges.map((e) => [e.id, e]));
54
+ const flows = new Map(graph.flows.map((f) => [f.id, f]));
55
+ const evidence = new Map(graph.evidence.map((e) => [e.id, e]));
56
+
57
+ const mint = (kind, subtype, label, stage, externality = 'internal') => {
58
+ const id = ids.nodeId(kind, [repository, `nix:${subtype}`, 'modeled', externality, '']);
59
+ let n = nodes.get(id);
60
+ if (!n) {
61
+ n = {
62
+ id, kind, subtype, label, aliases: [], location: null,
63
+ system: { application: repository, environment: null }, destination: null, storeDetail: null, queueDetail: null,
64
+ externality: { value: externality, evidenceRefs: [] }, lifecycleStages: [stage], governanceRefs: {},
65
+ dataElementIds: [], evidenceRefs: [], confidence: { score: 0.8, tier: 'high' }, coverageStatus: 'modeled', coverageReason: null,
66
+ analysis: ANALYSIS,
67
+ };
68
+ nodes.set(id, n); result.added.nodes++;
69
+ }
70
+ return n;
71
+ };
72
+ const source = mint('source', 'declared', 'Nix configuration value', 'collection');
73
+
74
+ for (const f of analysis.findings || []) {
75
+ const sink = SINKS[f.exposure];
76
+ if (!sink || !f.source) continue; // runtime references and unclassified exposures are not flows
77
+ const name = lastSegment(f.source.detail);
78
+ const deId = ids.dataElementId(name, [repository, f.source.file || f.file || '']);
79
+ let de = els.get(deId);
80
+ if (!de) {
81
+ const cls = classifyDataElementName(name).classes;
82
+ de = {
83
+ id: deId, name, aliases: [], declaredType: null, dataClasses: cls.length ? cls : ['CREDENTIALS'], aiContexts: [],
84
+ sourceLocations: [{ file: f.source.file || f.file, line: f.line, scope: 'nix', path: f.source.detail }],
85
+ dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
86
+ };
87
+ els.set(deId, de); result.added.dataElements++;
88
+ }
89
+ const snk = mint(sink.kind, sink.subtype, sink.label, sink.stage);
90
+ if (!source.dataElementIds.includes(deId)) source.dataElementIds.push(deId);
91
+ if (!snk.dataElementIds.includes(deId)) snk.dataElementIds.push(deId);
92
+
93
+ const edgeIdStr = ids.edgeId(source.id, snk.id, 'data_flow', [f.source.detail, f.exposure, deId, 'identity']);
94
+ if (!edges.has(edgeIdStr)) {
95
+ edges.set(edgeIdStr, {
96
+ id: edgeIdStr, from: source.id, to: snk.id, relationship: 'data_flow',
97
+ fieldMappings: [{ fromPath: f.source.detail, toPath: null, dataElementIds: [deId], mappingType: 'identity', transformationIds: [] }],
98
+ protocol: { name: 'nix-evaluation', destinationResolution: 'resolved_from_config' },
99
+ boundaryCrossings: [], provenance: 'code',
100
+ protection: { transit: { verdict: 'not_applicable', evidenceGrade: 'none' }, atRest: { verdict: f.exposure === 'log' ? 'not_assessed' : 'unprotected', evidenceGrade: 'config' }, handling: { verdict: 'not_assessed', evidenceGrade: 'none' } },
101
+ evidenceRefs: [], coverageStatus: 'modeled', analysis: ANALYSIS,
102
+ });
103
+ result.added.edges++;
104
+ }
105
+ const claim = `${f.rule}: ${f.source.detail} reaches ${sink.label}`;
106
+ const evId = ids.evidenceId(claim, `${f.file}:${f.line}`, [deId, f.exposure]);
107
+ if (!evidence.has(evId)) {
108
+ evidence.set(evId, {
109
+ id: evId, claim, evidenceType: 'configuration', location: { file: f.file, line: f.line, column: f.column ?? null, generatedLocation: f.generatedLocation || null },
110
+ producer: 'nix-secrets', confidenceTier: null, snippet: null, timestamp: null, commit: null,
111
+ limitations: ['static configuration evidence: the value is not proven to be present on a running host'], conflict: null,
112
+ });
113
+ }
114
+ const fId = ids.flowId(source.id, snk.id, [deId], [f.exposure, f.rule]);
115
+ if (!flows.has(fId)) {
116
+ flows.set(fId, {
117
+ id: fId, dataElementIds: [deId], source: source.id, sink: snk.id, edgeIds: [edgeIdStr], transformationIds: [],
118
+ alternatePathCount: 0, policyVerdict: 'not_evaluated', protectionSummary: f.exposure === 'log' ? 'not_assessed' : 'unprotected',
119
+ evidenceRefs: [evId], confidence: { score: 0.8, tier: 'high' }, coverageStatus: 'modeled', findingRefs: [], governanceRefs: {},
120
+ limitations: ['static Nix configuration evidence', ...(f.generatedLocation ? ['the value lands in a generated script; both the original and generated locations are recorded'] : [])],
121
+ evidenceGrade: 'explicit', handling: 'raw', analysis: ANALYSIS,
122
+ });
123
+ result.added.flows++;
124
+ } else {
125
+ const ex = flows.get(fId);
126
+ if (!ex.evidenceRefs.includes(evId)) ex.evidenceRefs.push(evId);
127
+ }
128
+ }
129
+
130
+ // Personal data (PII, PHI, PCI, financial) written into configuration: a field-to-store flow per unprotected reference.
131
+ let personal = { references: [], gaps: [] };
132
+ try { personal = analyzeNixPersonalData({ files }); } catch (e) { result.gaps.push(`nix personal-data analysis failed: ${String((e && e.message) || e)}`); }
133
+ result.gaps.push(...personal.gaps);
134
+ for (const ref of personal.references) {
135
+ if (ref.protected) continue; // hashed, measured or only compared: no flow of the value
136
+ const dest = destinationOf(ref.attr);
137
+ const sink = { kind: 'store', subtype: 'file', label: dest.label, stage: 'storage' };
138
+ const deId = ids.dataElementId(ref.field, [repository, ref.file]);
139
+ let de = els.get(deId);
140
+ if (!de) {
141
+ de = {
142
+ id: deId, name: ref.field, aliases: [], declaredType: null, dataClasses: ref.classes, aiContexts: [],
143
+ sourceLocations: [{ file: ref.file, line: ref.line, scope: 'nix', path: ref.option }],
144
+ dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
145
+ };
146
+ els.set(deId, de); result.added.dataElements++;
147
+ }
148
+ const snk = mint(sink.kind, sink.subtype, sink.label, sink.stage);
149
+ if (!source.dataElementIds.includes(deId)) source.dataElementIds.push(deId);
150
+ if (!snk.dataElementIds.includes(deId)) snk.dataElementIds.push(deId);
151
+ const edgeIdStr = ids.edgeId(source.id, snk.id, 'data_flow', [ref.option, 'personal-data', deId, 'identity']);
152
+ if (!edges.has(edgeIdStr)) {
153
+ edges.set(edgeIdStr, {
154
+ id: edgeIdStr, from: source.id, to: snk.id, relationship: 'data_flow',
155
+ fieldMappings: [{ fromPath: ref.option, toPath: ref.attr.join('.'), dataElementIds: [deId], mappingType: 'identity', transformationIds: [] }],
156
+ protocol: { name: 'nix-evaluation', destinationResolution: 'resolved_from_config' },
157
+ boundaryCrossings: [], provenance: 'code',
158
+ protection: { transit: { verdict: 'not_applicable', evidenceGrade: 'none' }, atRest: { verdict: 'unprotected', evidenceGrade: 'config' }, handling: { verdict: 'not_assessed', evidenceGrade: 'none' } },
159
+ evidenceRefs: [], coverageStatus: 'modeled', analysis: ANALYSIS,
160
+ });
161
+ result.added.edges++;
162
+ }
163
+ const claim = `nix-personal-data-in-store: ${ref.option} reaches ${sink.label}`;
164
+ const evId = ids.evidenceId(claim, `${ref.file}:${ref.line}`, [deId, 'personal-data']);
165
+ if (!evidence.has(evId)) {
166
+ evidence.set(evId, {
167
+ id: evId, claim, evidenceType: 'configuration', location: { file: ref.file, line: ref.line, column: null, generatedLocation: null },
168
+ producer: 'nix-privacy', confidenceTier: null, snippet: null, timestamp: null, commit: null,
169
+ limitations: ['static configuration evidence: the option may hold no real personal data, and the value is not proven present on a running host'], conflict: null,
170
+ });
171
+ }
172
+ const fId = ids.flowId(source.id, snk.id, [deId], ['personal-data', ref.attr.join('.')]);
173
+ if (!flows.has(fId)) {
174
+ flows.set(fId, {
175
+ id: fId, dataElementIds: [deId], source: source.id, sink: snk.id, edgeIds: [edgeIdStr], transformationIds: [],
176
+ alternatePathCount: 0, policyVerdict: 'not_evaluated', protectionSummary: 'unprotected',
177
+ evidenceRefs: [evId], confidence: { score: 0.7, tier: 'medium' }, coverageStatus: 'modeled', findingRefs: [], governanceRefs: {},
178
+ limitations: ['static Nix configuration evidence', 'classified by the option name only'],
179
+ evidenceGrade: 'inferred', handling: 'raw', analysis: ANALYSIS,
180
+ });
181
+ result.added.flows++;
182
+ } else {
183
+ const ex = flows.get(fId);
184
+ if (!ex.evidenceRefs.includes(evId)) ex.evidenceRefs.push(evId);
185
+ }
186
+ }
187
+
188
+ const byId = (a, b) => (a.id < b.id ? -1 : 1);
189
+ graph.nodes = [...nodes.values()].sort(byId);
190
+ graph.dataElements = [...els.values()].sort(byId);
191
+ graph.edges = [...edges.values()].sort(byId);
192
+ graph.flows = [...flows.values()].sort(byId);
193
+ graph.evidence = [...evidence.values()].sort(byId);
194
+ if (result.added.flows) {
195
+ graph.limitations = [...graph.limitations, 'Nix configuration lineage is static: a store path or declared service is not proof of runtime exposure.'];
196
+ }
197
+ return result;
198
+ }
@@ -116,6 +116,10 @@ export const CWE_MAP = Object.freeze({
116
116
  'CWE-22': Object.freeze({ category: 'file', status: 'modeled', why: 'path traversal sinks are filesystem reads/writes' }),
117
117
  'CWE-73': Object.freeze({ category: 'file', status: 'modeled', why: 'arbitrary file write' }),
118
118
  'CWE-918': Object.freeze({ category: 'external-api', status: 'modeled', why: 'SSRF sinks are outbound HTTP client calls' }),
119
+ // X-004 / haskell-models.js: untrusted text placed in a model request BODY. The call is an ordinary outbound HTTP
120
+ // body setter, so it is an external-api destination; whether the destination is a model provider is decided by
121
+ // the AI-evidence overlay (lineage/haskell-ai.js), never by this table.
122
+ 'CWE-1427': Object.freeze({ category: 'external-api', status: 'modeled', why: 'a model request body is an outbound HTTP request body' }),
119
123
  'CWE-601': Object.freeze({ category: 'http-response', status: 'modeled', why: 'a redirect is written as a response header' }),
120
124
  'CWE-113': Object.freeze({ category: 'http-response', status: 'modeled', why: 'response splitting — the sink IS the response header writer' }),
121
125
  // The sink side's ONE documented refinement (§5.2). `status: 'split'` is
@@ -136,6 +140,7 @@ export const CWE_MAP = Object.freeze({
136
140
  // memory). FR-201's category list is an EGRESS taxonomy and models none
137
141
  // of them — the single largest structural finding of D1 (§3/§7.1).
138
142
  'CWE-78': Object.freeze({ category: null, status: 'unsupported', why: 'shell/process execution — no FR-201 category models process execution' }),
143
+ 'CWE-88': Object.freeze({ category: null, status: 'unsupported', why: 'process-argument injection (Haskell argv sinks), no FR-201 category models process execution' }),
139
144
  'CWE-95': Object.freeze({ category: null, status: 'unsupported', why: 'code evaluation (eval/Function/exec/compile) — destination is an interpreter' }),
140
145
  'CWE-94': Object.freeze({ category: null, status: 'unsupported', why: 'code injection / template compilation — destination is an interpreter or template engine' }),
141
146
  // PHP SARD catalog addition: include/require executes the included file
@@ -291,7 +296,11 @@ export function reclassifySink(entry, opts = {}) {
291
296
  }
292
297
  if (row.status === 'split') {
293
298
  // The single documented refinement on the sink side (§5.2).
294
- row = DOM_FRAMEWORKS.has(entry.framework)
299
+ // A Haskell text-output sink (`putStrLn`) that is an XSS sink only when the string being assembled carries markup
300
+ // (`hs.htmlSkeleton`): the destination is standard output (a log), not a browser response.
301
+ row = entry.hs && entry.hs.htmlSkeleton
302
+ ? { category: 'log', status: 'modeled', why: 'text written to standard output (the console is a log destination); an XSS sink to the taint engine only when the assembled string carries markup' }
303
+ : DOM_FRAMEWORKS.has(entry.framework)
295
304
  ? { category: 'client-storage', status: 'partial', why: "LOSSY: the destination is the rendered browser DOM; schema.js's `client-storage` is its encoding of FR-201's 'browser DOM or client storage' bullet, and under-names the DOM half" }
296
305
  : { category: 'http-response', status: 'modeled', why: 'a server-side response writer (res.send / PrintWriter / echo / Fprintf)' };
297
306
  }
@@ -144,6 +144,9 @@ export const NO_PROVENANCE_OVERRIDES = Object.freeze({
144
144
  'py-os-getenv': 'env-value',
145
145
  'py-os-environ': 'env-value',
146
146
  'py-input': 'user-input',
147
+ // Haskell: a parameter of an exported function carries caller-supplied text or a customer record (no request is modelled)
148
+ 'hs-src-caller-controlled-param': 'user-input',
149
+ 'hs-lin-src-record-parameter': 'user-input',
147
150
  // Java / Servlet
148
151
  'java-request-getParameter': 'http-query',
149
152
  'java-request-getHeader': 'http-header',
@@ -195,6 +195,18 @@ export const TRANSFORM_CATALOG = Object.freeze([
195
195
  { id: 'py-hashlib-md5', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'md5', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'md5' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: MD5 is broken)", examples: ['hashlib.md5'] },
196
196
  { id: 'java-message-digest', language: 'java', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'MessageDigest', method: 'getInstance' }, evidence: "call to '{name}' — the JCA MessageDigest factory (the digest algorithm is an argument, not read here)", examples: ['MessageDigest.getInstance'] },
197
197
 
198
+ // Haskell (cryptonite / crypton / bcrypt / argon2 packages). A dotted callee is matched on its last two segments, so
199
+ // `Crypto.Hash.hashWith` is object `Hash`, method `hashWith`.
200
+ { id: 'hs-crypto-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash digest (the algorithm is a type or argument)", examples: ['Crypto.Hash.hash'] },
201
+ { id: 'hs-crypto-hashwith', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hashWith' }, evidence: "call to '{name}', Crypto.Hash digest with an explicit algorithm value", examples: ['Crypto.Hash.hashWith'] },
202
+ { id: 'hs-crypto-hashlazy', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'Hash', method: 'hashlazy' }, evidence: "call to '{name}', Crypto.Hash digest of a lazy value", examples: ['Crypto.Hash.hashlazy'] },
203
+ { id: 'hs-crypto-sha256', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha256', confidence: 'high', match: { type: 'member-call', object: 'SHA256', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash.SHA256", examples: ['Crypto.Hash.SHA256.hash'] },
204
+ { id: 'hs-crypto-sha512', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha512', confidence: 'high', match: { type: 'member-call', object: 'SHA512', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash.SHA512", examples: ['Crypto.Hash.SHA512.hash'] },
205
+ { id: 'hs-crypto-blake2', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'blake2', confidence: 'high', match: { type: 'member-call', object: 'Blake2b_256', method: 'hash' }, evidence: "call to '{name}', Crypto.Hash BLAKE2", examples: ['Crypto.Hash.Blake2b_256.hash'] },
206
+ { id: 'hs-hmac', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'HMAC', method: 'hmac' }, evidence: "call to '{name}', Crypto.MAC.HMAC keyed digest; still one-way", examples: ['Crypto.MAC.HMAC.hmac'] },
207
+ { id: 'hs-bcrypt-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'BCrypt', method: 'hashPassword' }, evidence: "call to '{name}', Crypto.KDF.BCrypt", examples: ['Crypto.KDF.BCrypt.hashPassword'] },
208
+ { id: 'hs-argon2-hash', language: 'hs', kind: 'hash', reversibility: 'irreversible', algorithm: 'argon2', confidence: 'high', match: { type: 'member-call', object: 'Argon2', method: 'hash' }, evidence: "call to '{name}', Crypto.KDF.Argon2", examples: ['Crypto.KDF.Argon2.hash'] },
209
+
198
210
  // ─── encrypt — reversible by design ────────────────────────────────────
199
211
  { id: 'js-node-cipheriv', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric cipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createCipheriv'] },
200
212
  { id: 'js-node-cipher-legacy', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipher' }, evidence: "call to '{name}' — Node's deprecated symmetric cipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createCipher'] },
@@ -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,