@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
@@ -0,0 +1,289 @@
1
+ // Haskell and Nix support registry and promotion gate (HS-011, QA-002).
2
+ //
3
+ // A capability row says what is SUPPORTED, from MEASURED evidence only. The registry never grants support from an installed parser,
4
+ // a structural-only detection, a metric of another kind, or labels that changed after the measurement. `evaluateSupport` is pure:
5
+ // it takes a measurement (bench/language-support/measure.mjs output) plus the results of the named test suites and returns each
6
+ // capability's status with the reasons it is or is not supported. Nothing here runs a scan or reads the corpus.
7
+ //
8
+ // supported every criterion for the row has passing evidence of the row's OWN metric kind, on the frozen corpus
9
+ // partial measured, some criteria met and some not (the unmet ones are listed)
10
+ // failed measured and below target
11
+ // not-measured no evidence of the right kind (a zero or absent denominator is never 100%)
12
+ // blocked a required tool or host is absent (a compiler, nix, a NixOS host): the criterion could not run, which is a
13
+ // failed criterion for support, never a skip
14
+
15
+ export const SUPPORT_REGISTRY_VERSION = 'language-support/1';
16
+
17
+ /** The section 9.2 numerical gates. */
18
+ export const TARGETS = Object.freeze({ precision: 0.9, recall: 0.85, f1: 0.8, perFamilyF1: 0.8, minHoldoutPerLabelPerFamily: 5, minPrivacyPositives: 100, minPrivacyNegatives: 100 });
19
+
20
+ /** The pairs gate is this project's own (the PRD sets none): semantics-changing pairs must ALL flip; preserving pairs may not drift much. */
21
+ export const PAIR_GATE = Object.freeze({ change: 1, preserve: 0.9 });
22
+
23
+ /** Required capabilities per ecosystem, with the metric kind each one may be promoted from. */
24
+ export const REQUIRED_CAPABILITIES = Object.freeze({
25
+ haskell: Object.freeze({
26
+ parser: 'parse-fixtures', sast: 'sast-holdout', taint: 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
27
+ auth: 'auth-detection', sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite',
28
+ }),
29
+ nix: Object.freeze({
30
+ parser: 'parse-fixtures', 'config-sast': 'sast-holdout', 'config-taint': 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
31
+ sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite', 'nix-eval': 'test-suite', 'nixos-host': 'host-run',
32
+ }),
33
+ });
34
+
35
+ /**
36
+ * Authentication and authorization are rated on DETECTION of the families that carry them (measured on the frozen holdout, gated like
37
+ * any other layer) AND on the web-model test suite passing. A pass/fail test run alone is not a measurement of how well missing or
38
+ * broken authentication is found, so it can never promote this row by itself.
39
+ */
40
+ export const AUTH_FAMILIES = Object.freeze(['route-authentication', 'object-authorization']);
41
+
42
+ /** Capabilities that need an external tool or host to be exercised at all: absent, the row is BLOCKED whatever its tests say. */
43
+ export const REQUIRES_TOOL = Object.freeze({
44
+ haskell: Object.freeze({ auth: Object.freeze({ tool: 'ghc', why: 'the route fixtures are compiled by GHC (HS-006.AC01)' }) }),
45
+ nix: Object.freeze({
46
+ 'nix-eval': Object.freeze({ tool: 'nix', why: 'a successful controlled evaluation needs a real nix binary (NIX-011.AC04)' }),
47
+ 'nixos-host': Object.freeze({ tool: 'nixos', why: 'the complete scanner must run on a NixOS host (NIX-012)' }),
48
+ }),
49
+ });
50
+
51
+ /** Metric kinds that can NEVER promote a row, whatever their value. */
52
+ export const NON_PROMOTING_EVIDENCE = Object.freeze(['installation', 'grammar-present', 'structural-only', 'documentation', 'model-assertion', 'taint-as-privacy', 'unknown']);
53
+
54
+ const ratio = (n, d) => (d > 0 ? n / d : null);
55
+
56
+ /**
57
+ * Exact (Clopper-Pearson) two-sided confidence interval for k successes in n trials. A rate of 30/30 is NOT "100%": its 95% lower
58
+ * bound is about 0.88, and a table that prints 1.000 without it invites exactly that misreading.
59
+ */
60
+ export function clopperPearson(k, n, alpha = 0.05) {
61
+ if (!(n > 0)) return null;
62
+ const binomTail = (p, from) => { // P(X >= from) for X ~ Bin(n, p), computed in log space
63
+ let sum = 0;
64
+ for (let i = from; i <= n; i++) {
65
+ let lc = 0; for (let j = 1; j <= i; j++) lc += Math.log((n - i + j) / j);
66
+ sum += Math.exp(lc + (p > 0 ? i * Math.log(p) : (i === 0 ? 0 : -Infinity)) + (p < 1 ? (n - i) * Math.log(1 - p) : (n - i === 0 ? 0 : -Infinity)));
67
+ }
68
+ return sum;
69
+ };
70
+ const bisect = (f, lo, hi) => { for (let i = 0; i < 80; i++) { const mid = (lo + hi) / 2; if (f(mid)) hi = mid; else lo = mid; } return (lo + hi) / 2; };
71
+ const lower = k === 0 ? 0 : bisect((p) => binomTail(p, k) >= alpha / 2, 0, 1);
72
+ const upper = k === n ? 1 : bisect((p) => binomTail(p, k + 1) >= 1 - alpha / 2, 0, 1);
73
+ return [lower, upper];
74
+ }
75
+ const ci = (k, n) => { const r = clopperPearson(k, n); return r ? [Number(r[0].toFixed(4)), Number(r[1].toFixed(4))] : null; };
76
+ const withInterval = (e) => (e && Number.isFinite(e.tp) ? { ...e, ci: { precision: ci(e.tp, e.tp + (e.fp || 0)), recall: ci(e.tp, e.tp + (e.fn || 0)), level: 0.95, method: 'Clopper-Pearson' } } : e);
77
+ const pct = (x) => (x == null ? 'not measured' : `${(x * 100).toFixed(1)}%`);
78
+
79
+ function rate(layer) {
80
+ if (!layer || !layer.measured) return { precision: null, recall: null, f1: null };
81
+ return { precision: layer.precision, recall: layer.recall, f1: layer.f1 };
82
+ }
83
+
84
+ /** Section 9.2 gate for one scored layer. Returns {ok, reasons}. `perFamilyCases` supplies the holdout denominators per family. */
85
+ export function gateLayer(layer, perFamilyCases, { kind }) {
86
+ const reasons = [];
87
+ if (!layer || !layer.measured) return { ok: false, reasons: [`${kind}: not measured (a zero or absent denominator is never 100%)`] };
88
+ const r = rate(layer);
89
+ if (!(r.precision >= TARGETS.precision)) reasons.push(`precision ${pct(r.precision)} < ${pct(TARGETS.precision)} (${layer.tp} TP, ${layer.fp} FP)`);
90
+ if (!(r.recall >= TARGETS.recall)) reasons.push(`recall ${pct(r.recall)} < ${pct(TARGETS.recall)} (${layer.tp} TP, ${layer.fn} FN)`);
91
+ if (!(r.f1 >= TARGETS.f1)) reasons.push(`F1 ${pct(r.f1)} < ${pct(TARGETS.f1)}`);
92
+ const fams = layer.families || {};
93
+ for (const [name, m] of Object.entries(fams)) {
94
+ // a family absent from the holdout cannot pass; a family with too few examples of either label cannot pass
95
+ const f1 = m.f1 == null ? null : m.f1;
96
+ if (!(f1 >= TARGETS.perFamilyF1)) reasons.push(`family ${name}: F1 ${pct(f1)} < ${pct(TARGETS.perFamilyF1)}`);
97
+ }
98
+ for (const [name, c] of Object.entries(perFamilyCases || {})) {
99
+ if (!(c.vulnerable >= TARGETS.minHoldoutPerLabelPerFamily && c.safe >= TARGETS.minHoldoutPerLabelPerFamily)) reasons.push(`family ${name}: holdout has ${c.vulnerable} vulnerable / ${c.safe} safe (< ${TARGETS.minHoldoutPerLabelPerFamily} each)`);
100
+ }
101
+ return { ok: reasons.length === 0, reasons };
102
+ }
103
+
104
+ function suiteStatus(results, names) {
105
+ // suites are keyed by file base name; a capability lists the files (paths) that back it
106
+ const rows = (names || []).map((n) => results && results[String(n).split('/').pop()]);
107
+ if (!(names || []).length || rows.some((r) => !r)) return { ok: false, status: 'not-measured', reasons: ['no test-suite evidence was recorded for this capability'] };
108
+ const reasons = [];
109
+ for (const r of rows) {
110
+ if (r.blocked) reasons.push(`${r.name}: blocked (${r.blocked})`);
111
+ else if (r.fail > 0) reasons.push(`${r.name}: ${r.fail} failing test(s)`);
112
+ if (r.skipped > 0) reasons.push(`${r.name}: ${r.skipped} skipped test(s) (a skip is not a pass)`);
113
+ if (!(r.tests > 0)) reasons.push(`${r.name}: no tests ran`);
114
+ }
115
+ if (reasons.some((x) => /blocked/.test(x))) return { ok: false, status: 'blocked', reasons };
116
+ return { ok: reasons.length === 0, status: reasons.length ? 'failed' : 'supported', reasons };
117
+ }
118
+
119
+ /**
120
+ * @param {string} language 'haskell' | 'nix'
121
+ * @param {{measurement: object, suites?: Record<string,object>, capabilitySuites?: Record<string,string[]>, frozen?: object}} input
122
+ * measurement: the holdout measurement; suites: test-suite results by name; capabilitySuites: which suites back each capability;
123
+ * frozen: {holdoutRollup, labelsSha256, privacyLabelsSha256} of the corpus AS IT IS NOW (a measurement on other data cannot promote).
124
+ */
125
+ export function evaluateSupport(language, input) {
126
+ const required = REQUIRED_CAPABILITIES[language];
127
+ if (!required) throw new Error(`unknown language ${language}`);
128
+ const m = input.measurement || {};
129
+ const eco = (m.ecosystems && m.ecosystems[language]) || {};
130
+ const out = { language, version: SUPPORT_REGISTRY_VERSION, rows: {}, summary: {} };
131
+ const dataOk = (() => {
132
+ if (!input.frozen) return { ok: false, reasons: ['no frozen corpus hashes were supplied to check the measurement against'] };
133
+ const h = m.hashes || {};
134
+ const reasons = [];
135
+ if (m.split !== 'holdout') reasons.push(`the measurement is on the "${m.split}" split, not the frozen holdout`);
136
+ if (h.holdoutRollup !== input.frozen.holdoutRollup) reasons.push('the measured holdout hash is not the current frozen holdout');
137
+ if (h.labelsSha256 !== input.frozen.labelsSha256) reasons.push('the measured labels are not the current labels (changed benchmark labels cannot promote a row)');
138
+ if (h.privacyLabelsSha256 !== input.frozen.privacyLabelsSha256) reasons.push('the measured privacy labels are not the current privacy labels');
139
+ return { ok: reasons.length === 0, reasons };
140
+ })();
141
+
142
+ const row = (capability, status, reasons, evidence) => { out.rows[capability] = { capability, status, reasons, evidence: evidence || null }; };
143
+ const detection = eco.detection || {};
144
+ const layerKey = (cap) => (/taint/.test(cap) ? 'taint' : 'sast');
145
+
146
+ for (const [cap, kind] of Object.entries(required)) {
147
+ if (kind === 'parse-fixtures') {
148
+ const p = eco.parser;
149
+ if (!p || !(p.validFixtures > 0)) { row(cap, 'not-measured', ['no parse-fixture measurement']); continue; }
150
+ const reasons = [];
151
+ if (p.validFixturesParsedCleanly !== p.validFixtures) reasons.push(`${p.validFixturesParsedCleanly}/${p.validFixtures} valid fixtures parsed cleanly`);
152
+ if ((p.crashed || []).length) reasons.push(`${p.crashed.length} crash(es)`);
153
+ const u = detection.unknownOutcomes;
154
+ if (u && (u.silentClean > 0 || u.requiredDisclosureMissing > 0)) reasons.push(`unknown cases: ${u.silentClean} silent-clean, ${u.requiredDisclosureMissing} undisclosed required boundary`);
155
+ row(cap, reasons.length ? 'failed' : 'supported', reasons, { metric: 'parse-fixtures', files: p.files, parsed: p.parsed, validFixtures: p.validFixtures, crashed: (p.crashed || []).length });
156
+ } else if (kind === 'sast-holdout' || kind === 'taint-holdout') {
157
+ const key = layerKey(cap);
158
+ const layer = (detection.layers || {})[key];
159
+ const g = gateLayer(layer, filterFamilies(detection.perFamilyCases, layer), { kind: key });
160
+ const reasons = [...g.reasons, ...dataOk.reasons];
161
+ const u = detection.unknownOutcomes;
162
+ if (u && u.silentClean > 0) reasons.push(`${u.silentClean} unknown case(s) came back with neither a finding nor a disclosure (false assurance)`);
163
+ row(cap, !layer || !layer.measured ? 'not-measured' : reasons.length ? 'failed' : 'supported', reasons, layer ? { metric: kind, layer: key, tp: layer.tp, fp: layer.fp, fn: layer.fn, tn: layer.tn, precision: layer.precision, recall: layer.recall, f1: layer.f1, cases: layer.cases, families: layer.families, strictPrecision: layer.strict && layer.strict.precision } : null);
164
+ } else if (kind === 'auth-detection') {
165
+ const sast = detection.layers && detection.layers.sast;
166
+ const fams = Object.fromEntries(AUTH_FAMILIES.filter((f) => sast && sast.families && sast.families[f]).map((f) => [f, sast.families[f]]));
167
+ if (!Object.keys(fams).length) { row(cap, 'not-measured', [`no measured authentication families (${AUTH_FAMILIES.join(', ')}): a passing test run is not a detection measurement`]); continue; }
168
+ const sum = (k) => Object.values(fams).reduce((n, x) => n + (x[k] || 0), 0);
169
+ const tp = sum('tp'); const fp = sum('fp'); const fn = sum('fn');
170
+ const precision = ratio(tp, tp + fp); const recall = ratio(tp, tp + fn);
171
+ const f1 = precision != null && recall != null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : null;
172
+ const pseudo = { measured: true, tp, fp, fn, tn: sum('tn'), precision, recall, f1, families: fams };
173
+ const g = gateLayer(pseudo, filterFamilies(detection.perFamilyCases, pseudo), { kind: 'auth' });
174
+ const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
175
+ const reasons = [...g.reasons, ...dataOk.reasons, ...(ts.ok ? [] : ts.reasons)];
176
+ row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'auth-detection', layer: 'sast', families: Object.keys(fams), tp, fp, fn, precision, recall, f1, suites: ((input.capabilitySuites || {})[cap] || []) });
177
+ } else if (kind === 'privacy-holdout') {
178
+ const p = eco.privacy;
179
+ if (!p || !(p.positives > 0 && p.negatives > 0)) { row(cap, 'not-measured', ['no privacy field-to-sink measurement (a security finding or a taint metric is never a privacy metric)']); continue; }
180
+ const reasons = [...dataOk.reasons];
181
+ if (!(p.precision >= TARGETS.precision)) reasons.push(`precision ${pct(p.precision)} < ${pct(TARGETS.precision)}`);
182
+ if (!(p.recall >= TARGETS.recall)) reasons.push(`recall ${pct(p.recall)} < ${pct(TARGETS.recall)}`);
183
+ if (!(p.f1 >= TARGETS.f1)) reasons.push(`F1 ${pct(p.f1)} < ${pct(TARGETS.f1)}`);
184
+ const tot = (m.corpusTotals && m.corpusTotals[language] && m.corpusTotals[language].privacy) || null;
185
+ if (!tot || !(tot.positives >= TARGETS.minPrivacyPositives && tot.negatives >= TARGETS.minPrivacyNegatives)) reasons.push(`the corpus has ${tot ? `${tot.positives} positive / ${tot.negatives} negative` : 'unknown'} field-to-sink expectations (< ${TARGETS.minPrivacyPositives} each)`);
186
+ row(cap, reasons.length ? 'failed' : 'supported', reasons, { metric: 'privacy-holdout', positives: p.positives, negatives: p.negatives, tp: p.tp, fp: p.fp, fn: p.fn, tn: p.tn, precision: p.precision, recall: p.recall, f1: p.f1, byKind: p.byKind });
187
+ } else if (kind === 'sca-fixtures') {
188
+ const s = m.supply || {};
189
+ const reasons = [];
190
+ const man = s.manifests; const rng = s.advisoryRanges;
191
+ if (!man || !man.total) reasons.push('no manifest-resolution measurement');
192
+ else if (man.exact !== man.total) reasons.push(`${man.exact}/${man.total} manifest fixtures exact`);
193
+ if (language === 'haskell') { if (!rng || !rng.total) reasons.push('no advisory-range replay'); else if (rng.correct !== rng.total) reasons.push(`${rng.correct}/${rng.total} advisory range checks correct`); }
194
+ const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
195
+ if (!ts.ok) reasons.push(...ts.reasons);
196
+ row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'sca-fixtures', manifests: man ? `${man.exact}/${man.total}` : null, advisoryRanges: rng ? `${rng.correct}/${rng.total}` : null });
197
+ } else if (kind === 'fix-fixtures') {
198
+ const f = eco.fixes;
199
+ if (!f) { row(cap, 'not-measured', ['no fix-fixture measurement']); continue; }
200
+ const reasons = [];
201
+ if (f.accept.correct !== f.accept.total) reasons.push(`${f.accept.correct}/${f.accept.total} valid proposals accepted`);
202
+ if (f.reject.correct !== f.reject.total) reasons.push(`${f.reject.correct}/${f.reject.total} bad proposals rejected`);
203
+ if (f.advertised.verified !== f.advertised.planned) reasons.push(`${f.advertised.verified}/${f.advertised.planned} advertised deterministic fixes verified`);
204
+ const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
205
+ if (!ts.ok) reasons.push(...ts.reasons);
206
+ const pairs = eco.pairs;
207
+ row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'fix-fixtures', accepted: `${f.accept.correct}/${f.accept.total}`, rejected: `${f.reject.correct}/${f.reject.total}`, advertised: `${f.advertised.verified}/${f.advertised.planned}`, unsupportedShapes: f.advertised.unsupported, pairs: pairs ? { preserve: `${pairs.preserve.held}/${pairs.preserve.total}`, change: `${pairs.change.held}/${pairs.change.total}` } : null });
208
+ } else if (kind === 'test-suite' || kind === 'host-run') {
209
+ const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
210
+ row(cap, ts.status === 'supported' ? 'supported' : ts.status, ts.reasons, { metric: kind, suites: ((input.capabilitySuites || {})[cap] || []) });
211
+ }
212
+ }
213
+ // 95% intervals beside every measured rate, and the strict precision (which counts findings of other families) beside the scoped one
214
+ for (const r of Object.values(out.rows)) {
215
+ if (!r.evidence || !Number.isFinite(r.evidence.tp)) continue;
216
+ const key = /taint/.test(r.capability) ? 'taint' : 'sast';
217
+ const strict = r.evidence.metric === 'sast-holdout' || r.evidence.metric === 'taint-holdout' ? (detection.layers || {})[key] && (detection.layers[key].strict || null) : null;
218
+ r.evidence = withInterval({ ...r.evidence, ...(strict && Number.isFinite(strict.precision) ? { strictPrecision: strict.precision } : {}) });
219
+ }
220
+ // Generalisation: the SAME layers measured on shapes absent from train, validation and holdout. It never changes a row's status (the
221
+ // PRD's gates are defined on the frozen holdout); it is stated beside it, with the shortfalls named, so "supported" is not read as more.
222
+ const un = input.unseen && input.unseen.ecosystems && input.unseen.ecosystems[language] && input.unseen.ecosystems[language].detection;
223
+ out.generalizationGaps = [];
224
+ for (const r of Object.values(out.rows)) {
225
+ if (!r.evidence || !/^(sast|taint)-holdout$/.test(r.evidence.metric || '')) continue;
226
+ const key = /taint/.test(r.capability) ? 'taint' : 'sast';
227
+ const u = un && un.layers && un.layers[key];
228
+ if (!u || !u.measured) { r.generalization = { status: 'not-measured' }; continue; }
229
+ const shortfalls = [];
230
+ if (!(u.precision >= TARGETS.precision)) shortfalls.push(`precision ${pct(u.precision)} < ${pct(TARGETS.precision)}`);
231
+ if (!(u.recall >= TARGETS.recall)) shortfalls.push(`recall ${pct(u.recall)} < ${pct(TARGETS.recall)}`);
232
+ if (!(u.f1 >= TARGETS.f1)) shortfalls.push(`F1 ${pct(u.f1)} < ${pct(TARGETS.f1)}`);
233
+ r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ci(u.tp, u.tp + u.fp), recall: ci(u.tp, u.tp + u.fn), level: 0.95, method: 'Clopper-Pearson' } };
234
+ if (shortfalls.length) out.generalizationGaps.push(`${r.capability}: ${shortfalls.join(', ')}`);
235
+ }
236
+ // a capability whose tool is absent is blocked, not passed and not skipped
237
+ const tools = input.tools || {};
238
+ for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
239
+ const r = out.rows[cap];
240
+ if (r && !tools[req.tool]) out.rows[cap] = { ...r, status: 'blocked', reasons: [`${req.tool} is not available on the measuring host: ${req.why}`, ...r.reasons] };
241
+ }
242
+ const rows = Object.values(out.rows);
243
+ out.summary = { required: rows.length, supported: rows.filter((r) => r.status === 'supported').length, notSupported: rows.filter((r) => r.status !== 'supported').map((r) => `${r.capability}:${r.status}`), dataOk: dataOk.ok };
244
+ const pairs = eco.pairs;
245
+ if (pairs) {
246
+ const pr = pairs.preserve.total ? pairs.preserve.held / pairs.preserve.total : null; const ch = pairs.change.total ? pairs.change.held / pairs.change.total : null;
247
+ out.metamorphic = { preserve: pr, change: ch, gate: PAIR_GATE, ok: pr != null && ch != null && pr >= PAIR_GATE.preserve && ch >= PAIR_GATE.change, broken: [...pairs.preserve.broken, ...pairs.change.broken] };
248
+ }
249
+ return out;
250
+ }
251
+
252
+ // Only the families the layer actually scored are held to the holdout-denominator rule.
253
+ function filterFamilies(perFamilyCases, layer) {
254
+ if (!layer || !layer.families) return perFamilyCases;
255
+ const wanted = new Set(Object.keys(layer.families));
256
+ return Object.fromEntries(Object.entries(perFamilyCases || {}).filter(([k]) => wanted.has(k)));
257
+ }
258
+
259
+ /**
260
+ * The promotion check applied to one proposed registry row. A row may be marked `supported` only if `evidence.metric` is the kind
261
+ * the capability requires, the evidence is not of a non-promoting kind, and `evaluateSupport` agrees. Returns {ok, reasons}.
262
+ */
263
+ export function checkPromotion(language, capability, proposedStatus, evidence, evaluated) {
264
+ const required = REQUIRED_CAPABILITIES[language];
265
+ const reasons = [];
266
+ if (!required || !(capability in required)) return { ok: false, reasons: [`${capability} is not a required capability of ${language}`] };
267
+ if (proposedStatus !== 'supported') return { ok: true, reasons: [] };
268
+ const metric = evidence && evidence.metric;
269
+ if (!metric) reasons.push('no evidence metric was named');
270
+ else if (NON_PROMOTING_EVIDENCE.includes(metric)) reasons.push(`evidence of kind "${metric}" can never promote a row`);
271
+ else if (metric !== required[capability]) reasons.push(`the row needs "${required[capability]}" evidence, not "${metric}" (a metric of another kind is not a measurement of this capability)`);
272
+ const actual = evaluated && evaluated.rows[capability];
273
+ if (!actual) reasons.push('the evaluated measurement has no such row');
274
+ else if (actual.status !== 'supported') reasons.push(`the measurement does not support it: ${actual.status}${actual.reasons.length ? ` (${actual.reasons[0]})` : ''}`);
275
+ return { ok: reasons.length === 0, reasons };
276
+ }
277
+
278
+ /**
279
+ * Re-checks a stored registry against the corpus as it is now: a row whose recorded hashes no longer match is demoted to
280
+ * `unverified` (changed labels never keep a promotion).
281
+ */
282
+ export function verifyRegistry(registry, frozen) {
283
+ const stale = [];
284
+ for (const [lang, entry] of Object.entries((registry && registry.languages) || {})) {
285
+ const f = entry.frozen || {};
286
+ for (const k of ['holdoutRollup', 'labelsSha256', 'privacyLabelsSha256']) if (f[k] !== (frozen || {})[k]) { stale.push({ language: lang, field: k }); }
287
+ }
288
+ return { ok: stale.length === 0, stale };
289
+ }
@@ -0,0 +1,38 @@
1
+ // Where an advisory snapshot may come from (trust boundary).
2
+ //
3
+ // An advisory snapshot decides which dependencies are reported as vulnerable. A scanned project is untrusted input: if the project could
4
+ // supply its own snapshot, a pull request could ship an empty, perfectly well-formed, freshly dated one and the scan would report
5
+ // "complete" with the vulnerable dependency gone. So a snapshot is accepted only from the operator:
6
+ // 1. the environment variable named by the caller (a path the operator set), or
7
+ // 2. a file in the operator's per-user configuration directory ($XDG_CONFIG_HOME/agentic-security, or ~/.config/agentic-security),
8
+ // the same directory that holds the per-install signing key.
9
+ // A file of the same name inside the scanned project is never read. It is reported, so the operator knows why it had no effect.
10
+
11
+ import { existsSync, lstatSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { statePath } from '../posture/state-dir.js';
15
+
16
+ /** The operator's per-user configuration directory. */
17
+ export function operatorConfigDir(env = process.env) {
18
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length ? env.XDG_CONFIG_HOME : join(homedir(), '.config');
19
+ return join(base, 'agentic-security');
20
+ }
21
+
22
+ const isRegularFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } };
23
+
24
+ /**
25
+ * Resolves a snapshot path from operator-controlled places only.
26
+ * @returns {{path: string|null, source: 'env'|'operator-config'|null, projectLocalIgnored: string|null}}
27
+ */
28
+ export function resolveOperatorSnapshot({ envVar, fileName, root = null, env = process.env }) {
29
+ const local = root ? statePath(root, fileName) : null;
30
+ const projectLocalIgnored = local && existsSync(local) ? local : null;
31
+ if (env[envVar]) return { path: env[envVar], source: 'env', projectLocalIgnored };
32
+ const cfg = join(operatorConfigDir(env), fileName);
33
+ if (isRegularFile(cfg)) return { path: cfg, source: 'operator-config', projectLocalIgnored };
34
+ return { path: null, source: null, projectLocalIgnored };
35
+ }
36
+
37
+ /** The sentence added to a "no snapshot" reason when the project carried one that was deliberately ignored. */
38
+ export const IGNORED_NOTE = (fileName) => ` A ${fileName} inside the scanned project was IGNORED: a project cannot vouch for its own advisories (provide the snapshot through the environment variable or your per-user configuration directory).`;
@@ -0,0 +1,185 @@
1
+ // Controlled proof witnesses for Haskell and Nix findings (X-009).
2
+ //
3
+ // A witness is a small, bounded program that DEMONSTRATES the predicted effect inside the confinement sandbox
4
+ // (src/sandbox: writes confined to a temporary root, no network, wall-clock and resource limits, fail-closed when no
5
+ // primitive is available). The effect is always the same observable: the witness payload creates the marker file
6
+ // `PROVEN` in its working directory. Nothing else counts.
7
+ //
8
+ // Supported witnesses, stated explicitly:
9
+ //
10
+ // nix-shell A shell script a Nix expression generates (systemd script, build phase). The generated script text is
11
+ // taken from the NIX-003 analysis, the interpolation the finding concerns is replaced by an attacker
12
+ // payload shaped for its shell context and passed through the SAME escaping the Nix code applies
13
+ // (lib.escapeShellArg), and the script is run for real by the host shell. The payload is only shell
14
+ // text: it runs only inside the sandbox, and only `touch PROVEN` is ever asked of it.
15
+ // haskell Needs a Haskell toolchain (runghc). Without one the result is `unavailable`, which is neither proof
16
+ // nor refutation. This module never installs or downloads a toolchain.
17
+ //
18
+ // What is NOT proof, ever: a syntax error in the witness, a missing executable, a sandbox that could not start or was
19
+ // refused, a timeout or resource limit, or an output cap. Each is reported as `invalid` with its reason and leaves
20
+ // the finding's static proof tier unchanged. Only the marker proves; its absence after a clean run is
21
+ // `not-reproduced`, which is evidence about THIS witness, not a proof that the code is safe.
22
+
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { spawnSync } from 'node:child_process';
27
+ import { runConfined, sandboxAvailable } from '../sandbox/index.js';
28
+ import { analyzeNixScripts, SHELL_PLACEHOLDER } from './nix-script-taint.js';
29
+
30
+ export const WITNESS_VERSION = 'language-witness/1';
31
+ export const PROOF_MARKER = 'PROVEN';
32
+ export const WITNESS_STATUS = Object.freeze(['reproduced', 'not-reproduced', 'invalid', 'unavailable', 'unsupported']);
33
+ const SHELLS = new Set(['bash', 'sh', 'dash', 'ash', 'ksh', 'mksh']);
34
+ const DEFAULT_TIMEOUT_MS = 20_000;
35
+ const MAX_SCRIPT_BYTES = 256 * 1024;
36
+
37
+ const shEscape = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
38
+
39
+ /** The text one placeholder takes in the generated script, given the Nix-side protection and the shell context. */
40
+ export function witnessPayload(context, protection) {
41
+ const cmd = `touch ${PROOF_MARKER}`;
42
+ let raw;
43
+ if (context === 'single') raw = `'; ${cmd}; echo '`;
44
+ else if (context === 'double') raw = `$(${cmd})`;
45
+ else raw = `$(${cmd})`;
46
+ // escapeShellArg(s) wraps in single quotes at EVALUATION time, before the shell ever sees the text. The shell then
47
+ // reads that result inside whatever quote context the script put it in, which is exactly the wrong-context case.
48
+ if (protection === 'escapeShellArg') return shEscape(raw);
49
+ if (protection === 'escapeShellArgs') return shEscape(raw);
50
+ return raw;
51
+ }
52
+
53
+ const placeholderIndexes = (text) => { const out = []; for (let i = 0; i < text.length; i++) if (text[i] === SHELL_PLACEHOLDER) out.push(i); return out; };
54
+ const indexOfPosition = (text, line, col) => { let l = 1; let i = 0; while (i < text.length && l < line) { if (text[i] === '\n') l++; i++; } return i + col; };
55
+
56
+ /**
57
+ * Builds a witness for one flow of one generated script.
58
+ * @param {{script:{generated:string, shell:string|null, attrPath:string, file:string}, flow:{generatedLocation:{line:number,startColumn:number}, context:string, protection:string|null}}} t
59
+ */
60
+ export function buildNixScriptWitness({ script, flow }) {
61
+ if (!script || typeof script.generated !== 'string' || !flow || !flow.generatedLocation) return { status: 'unsupported', reason: 'the flow has no generated-script location' };
62
+ const shell = script.shell || 'bash';
63
+ if (!SHELLS.has(shell)) return { status: 'unsupported', reason: `shell "${shell}" is not supported by the witness runner` };
64
+ if (Buffer.byteLength(script.generated) > MAX_SCRIPT_BYTES) return { status: 'unsupported', reason: 'the generated script exceeds the witness size limit' };
65
+ const all = placeholderIndexes(script.generated);
66
+ const at = indexOfPosition(script.generated, flow.generatedLocation.line, flow.generatedLocation.startColumn);
67
+ const ordinal = all.indexOf(at);
68
+ if (ordinal < 0) return { status: 'unsupported', reason: 'the flow location does not match an interpolation in the generated script' };
69
+ const payload = witnessPayload(flow.context, flow.protection);
70
+ let out = ''; let n = 0;
71
+ for (const ch of script.generated) { if (ch === SHELL_PLACEHOLDER) { out += n === ordinal ? payload : 'benign'; n++; } else out += ch; }
72
+ const body = out.replace(/^#!.*\n/, '');
73
+ return { status: 'ready', lang: 'nix-shell', shell, ordinal, attrPath: script.attrPath, file: script.file, payload, code: `${body}\n`, context: flow.context, protection: flow.protection || null };
74
+ }
75
+
76
+ function which(name) {
77
+ for (const d of String(process.env.PATH || '').split(path.delimiter)) { const p = path.join(d, name); try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ } }
78
+ return null;
79
+ }
80
+
81
+ /** Runs a witness built by buildNixScriptWitness inside the confinement sandbox. Never throws. */
82
+ export function runNixScriptWitness(w, { timeoutMs = DEFAULT_TIMEOUT_MS, force } = {}) {
83
+ const base = { witness: WITNESS_VERSION, lang: 'nix-shell', ran: false, backend: null, marker: PROOF_MARKER };
84
+ if (!w || w.status !== 'ready') return { ...base, status: w && w.status === 'unsupported' ? 'unsupported' : 'invalid', reason: (w && w.reason) || 'no witness' };
85
+ const shellBin = which(w.shell);
86
+ if (!shellBin) return { ...base, status: 'invalid', reason: `executable not found: ${w.shell}` };
87
+ if (!sandboxAvailable()) return { ...base, status: 'invalid', reason: 'no confinement primitive available; refusing to execute' };
88
+ let root;
89
+ try {
90
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'witness-')));
91
+ fs.writeFileSync(path.join(root, 'witness.sh'), w.code, 'utf8');
92
+ // 1. a witness that does not parse proves nothing in either direction
93
+ const syn = runConfined([shellBin, '-n', 'witness.sh'], { root, timeoutMs, force });
94
+ if (syn.status === 'disabled' || syn.status === 'error') return { ...base, backend: syn.backend, status: 'invalid', reason: `the sandbox could not run the syntax check (${syn.backend}): ${String(syn.stderr || '').trim() || 'no detail'}` };
95
+ if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget' };
96
+ if (syn.exitCode !== 0) return { ...base, backend: syn.backend, status: 'invalid', reason: `syntax error in the generated witness: ${String(syn.stderr || '').trim().split('\n')[0] || 'shell rejected it'}` };
97
+ // 2. the real run
98
+ const r = runConfined([shellBin, 'witness.sh'], { root, timeoutMs, force });
99
+ const ran = !r.timedOut && r.status !== 'disabled' && r.status !== 'error';
100
+ if (!ran) return { ...base, backend: r.backend, status: 'invalid', reason: r.timedOut ? 'the witness exceeded its time budget' : `the sandbox could not execute it (${r.backend}): ${String(r.stderr || '').trim() || r.status}`, timedOut: !!r.timedOut };
101
+ const proven = fs.existsSync(path.join(root, PROOF_MARKER));
102
+ return { ...base, ran: true, backend: r.backend, status: proven ? 'reproduced' : 'not-reproduced', exitCode: r.exitCode,
103
+ observed: proven ? `the payload created '${PROOF_MARKER}' by running inside the generated script` : null,
104
+ reason: proven ? null : 'the script ran and the marker was not created: this witness did not reproduce the effect' };
105
+ } catch (e) {
106
+ return { ...base, status: 'invalid', reason: `witness harness error: ${String((e && e.message) || e)}` };
107
+ } finally { if (root) fs.rmSync(root, { recursive: true, force: true }); }
108
+ }
109
+
110
+ /** Finds the flow for (attrPath, ordinal) in a fresh analysis of `files`, and builds+runs its witness. */
111
+ export function witnessNixFlow(files, target, opts = {}) {
112
+ const a = analyzeNixScripts({ files });
113
+ const script = a.scripts.find((s) => s.attrPath === target.attrPath && (!target.file || s.file === target.file));
114
+ if (!script) return { status: 'unsupported', ran: false, reason: `no generated script ${target.attrPath}` };
115
+ const idx = placeholderIndexes(script.generated);
116
+ const flow = a.flows.find((f) => f.attrPath === script.attrPath && f.generatedLocation && idx.indexOf(indexOfPosition(script.generated, f.generatedLocation.line, f.generatedLocation.startColumn)) === target.ordinal);
117
+ if (!flow) return { status: 'unsupported', ran: false, reason: `no interpolation #${target.ordinal} in ${target.attrPath}` };
118
+ return runNixScriptWitness(buildNixScriptWitness({ script, flow }), opts);
119
+ }
120
+
121
+ /** The (attrPath, ordinal) of the flow a finding describes, taken from a fresh analysis. */
122
+ export function nixFlowTarget(files, finding) {
123
+ const a = analyzeNixScripts({ files });
124
+ const flow = a.flows.find((f) => f.file === finding.file && f.line === finding.line && f.attrPath && f.generatedLocation)
125
+ || a.flows.find((f) => f.file === finding.file && f.attrPath === finding.attrPath && f.generatedLocation);
126
+ if (!flow) return null;
127
+ const script = a.scripts.find((s) => s.attrPath === flow.attrPath && s.file === (flow.generatedLocation.file || finding.file));
128
+ if (!script) return null;
129
+ const ordinal = placeholderIndexes(script.generated).indexOf(indexOfPosition(script.generated, flow.generatedLocation.line, flow.generatedLocation.startColumn));
130
+ return ordinal < 0 ? null : { attrPath: script.attrPath, file: script.file, ordinal };
131
+ }
132
+
133
+ /**
134
+ * Before/after judgement for a fix. A fix is witness-verified only when the ORIGINAL reproduced and the PATCHED code
135
+ * ran cleanly without reproducing. Anything invalid on either side verifies nothing.
136
+ */
137
+ export function judgeFixWitness(before, after) {
138
+ if (!before || before.status !== 'reproduced') return { ok: false, verified: false, reason: `the original code did not reproduce (${(before && before.status) || 'no witness'}${before && before.reason ? `: ${before.reason}` : ''}), so the fix cannot be witness-verified` };
139
+ if (!after || after.status === 'invalid' || after.status === 'unavailable' || after.status === 'unsupported') return { ok: false, verified: false, reason: `the patched witness could not be judged (${(after && after.status) || 'no witness'}${after && after.reason ? `: ${after.reason}` : ''})` };
140
+ if (after.status === 'reproduced') return { ok: false, verified: false, stillReproduces: true, reason: 'the patched code still reproduces the effect' };
141
+ return { ok: true, verified: true, reason: null, note: 'the witness reproduced before the patch and not after; this is evidence about this witness, not a proof of safety' };
142
+ }
143
+
144
+ // ── Haskell: optional, toolchain-gated ───────────────────────────────────────
145
+ /** Whether a Haskell runner exists. Never installs anything. */
146
+ export function haskellRunner() {
147
+ const p = which('runghc') || which('runhaskell');
148
+ if (!p) return { available: false, reason: 'no Haskell toolchain (runghc) on PATH; witnesses for Haskell findings are unavailable here' };
149
+ const v = spawnSync(p, ['--version'], { encoding: 'utf8', timeout: 10_000 });
150
+ return v.status === 0 ? { available: true, path: p, version: String(v.stdout || v.stderr).trim() } : { available: false, reason: 'the Haskell runner did not start' };
151
+ }
152
+
153
+ /**
154
+ * Runs a Haskell program text as a witness. `main` must create PROVEN in its working directory when the effect is
155
+ * reproduced. Without a toolchain the answer is `unavailable`: not proof, not refutation.
156
+ */
157
+ export function runHaskellWitness(source, { timeoutMs = 60_000, force } = {}) {
158
+ const base = { witness: WITNESS_VERSION, lang: 'haskell', ran: false, backend: null, marker: PROOF_MARKER };
159
+ const hr = haskellRunner();
160
+ if (!hr.available) return { ...base, status: 'unavailable', reason: hr.reason };
161
+ if (!sandboxAvailable()) return { ...base, status: 'invalid', reason: 'no confinement primitive available; refusing to execute' };
162
+ let root;
163
+ try {
164
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'witness-hs-')));
165
+ fs.writeFileSync(path.join(root, 'Witness.hs'), source, 'utf8');
166
+ const r = runConfined([hr.path, 'Witness.hs'], { root, timeoutMs, force });
167
+ if (r.status === 'disabled' || r.status === 'error') return { ...base, backend: r.backend, status: 'invalid', reason: `the sandbox could not execute it: ${String(r.stderr || '').trim() || r.status}` };
168
+ if (r.timedOut) return { ...base, backend: r.backend, status: 'invalid', reason: 'the witness exceeded its time budget', timedOut: true };
169
+ if (/error|parse error|Could not find module/i.test(String(r.stderr || '')) && r.exitCode !== 0) return { ...base, backend: r.backend, status: 'invalid', reason: `the program did not compile or run: ${String(r.stderr).trim().split('\n')[0]}` };
170
+ const proven = fs.existsSync(path.join(root, PROOF_MARKER));
171
+ return { ...base, ran: true, backend: r.backend, status: proven ? 'reproduced' : 'not-reproduced', exitCode: r.exitCode, reason: proven ? null : 'the program ran and the marker was not created' };
172
+ } catch (e) {
173
+ return { ...base, status: 'invalid', reason: `witness harness error: ${String((e && e.message) || e)}` };
174
+ } finally { if (root) fs.rmSync(root, { recursive: true, force: true }); }
175
+ }
176
+
177
+ /** A `witness` hook for runFixLifecycle over a Nix generated-script finding: runs the original and the patched tree. */
178
+ export function nixFixWitness(finding, opts = {}) {
179
+ return (plan, files, patched) => {
180
+ const nixOnly = (t) => Object.fromEntries(Object.entries(t).filter(([p, x]) => /\.nix$/i.test(p) && typeof x === 'string'));
181
+ const target = nixFlowTarget(nixOnly(files), finding);
182
+ if (!target) return { ok: false, verified: false, reason: 'the finding is not a generated-script interpolation, so no witness applies' };
183
+ return judgeFixWitness(witnessNixFlow(nixOnly(files), target, opts), witnessNixFlow(nixOnly(patched), target, opts));
184
+ };
185
+ }
@@ -1201,3 +1201,11 @@ way `recipient-profiles.json`/`privacy-policy.json` already are).
1201
1201
  allowed to live. No other module in this package — and per PRD Appendix
1202
1202
  D.1, no UI code in a later milestone — may special-case a fixture name.
1203
1203
  The generic hook is `graph.scope.source === 'fixture'`.
1204
+
1205
+ ## Haskell and Nix views
1206
+
1207
+ `haskell-view.js` rewrites a clone of each Haskell function so a monadic source read becomes a seedable member read on the
1208
+ synthetic root `$hs`, seeds record-parameter sources, and treats `length`/`null` as non-content. `nix-view.js` builds
1209
+ field-to-store flows from `language/nix-privacy.js` (a personal-data `config.<module>.<field>` interpolated into another option;
1210
+ hashed, measured or compared references are protected and make no flow). Coverage reports `unknown` for both languages: no
1211
+ taint-recall tier is borrowed. See `scanner/src/language/CLAUDE.md`.
@@ -328,6 +328,8 @@ const LANGUAGE_EXT_PATTERNS = Object.freeze([
328
328
  [/\.go$/i, 'go'],
329
329
  [/\.(?:php|phtml)$/i, 'php'],
330
330
  [/\.rb$/i, 'ruby'],
331
+ [/\.l?hs(?:c|ig)?$|\.hs-boot$/i, 'haskell'],
332
+ [/\.nix$/i, 'nix'],
331
333
  [/\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/i, 'cpp'],
332
334
  // Milestone 5, language coverage-tier disclosure: these 4 have ZERO
333
335
  // lineage/taint wiring (tree-sitter-pattern-only, sast/tree-sitter-sinks.js
@@ -99,6 +99,28 @@ export function validateCrossRepoLink(record) {
99
99
  _validateEndpoint(record.local, 'local', err);
100
100
  _validateEndpoint(record.remote, 'remote', err, ['repository', 'sourceFile']);
101
101
 
102
+ // Optional scope and evidence. Haskell and Nix endpoints carry an analysis scope (static configuration is not runtime
103
+ // exposure) and limitations; a link records them rather than flattening them away. `evidence` states that a link is a
104
+ // DECLARATION: it is an operator assertion, never an observed data flow, and a record claiming otherwise is invalid.
105
+ if (record.scope !== undefined && record.scope !== null) {
106
+ if (!_isPlainObject(record.scope)) err('$.scope', 'scope must be an object');
107
+ else {
108
+ for (const side of ['local', 'remote']) {
109
+ const sc = record.scope[side];
110
+ if (sc === undefined || sc === null) continue;
111
+ if (!_isPlainObject(sc)) { err(`$.scope.${side}`, `scope.${side} must be an object`); continue; }
112
+ if (!_isStringOrNull(sc.target)) err(`$.scope.${side}.target`, 'target must be a string or null');
113
+ if (!_isStringOrNull(sc.config)) err(`$.scope.${side}.config`, 'config must be a string or null');
114
+ if (sc.analysis !== undefined && sc.analysis !== null && !_isPlainObject(sc.analysis)) err(`$.scope.${side}.analysis`, 'analysis must be an object or null');
115
+ if (sc.limitations !== undefined && !(Array.isArray(sc.limitations) && sc.limitations.every((x) => typeof x === 'string'))) err(`$.scope.${side}.limitations`, 'limitations must be an array of strings');
116
+ }
117
+ }
118
+ }
119
+ if (record.evidence !== undefined && record.evidence !== null) {
120
+ if (!_isPlainObject(record.evidence) || record.evidence.declared !== true || record.evidence.observed !== false) {
121
+ err('$.evidence', 'evidence must be {declared: true, observed: false}: a declared link is never an observed data flow');
122
+ }
123
+ }
102
124
  if (!_isStringOrNull(record.rationale)) err('$.rationale', 'rationale must be a string or null');
103
125
  if (!_isNonEmptyString(record.declaredBy)) err('$.declaredBy', 'declaredBy is required');
104
126
  if (!_isNonEmptyString(record.declaredAt)) err('$.declaredAt', 'declaredAt is required');
@@ -171,7 +171,11 @@ function resolveSinkAtCallSite(calleeExpr, file) {
171
171
  const pool = justified.length > 1 ? justified : cands;
172
172
  const cats = [...new Set(pool.map((c) => c.decision.category))];
173
173
  if (cats.length === 1) {
174
- return { ...pool[0], ambiguity: { resolvedBy: 'unanimous-category', alternatives: pool.slice(1).map((c) => c.entry.id).sort() } };
174
+ // Haskell: a security sink watches the QUERY/URL argument, a privacy sink watches the DATA argument. When both
175
+ // agree on the destination, the data-bearing (privacy) entry is the one a field-level flow can reach.
176
+ const dataBearing = /\.l?hs$/i.test(String(file)) ? pool.find((c) => c.entry.vuln && c.entry.vuln.cwe === 'CWE-359') : null;
177
+ const pick = dataBearing || pool[0];
178
+ return { ...pick, ambiguity: { resolvedBy: 'unanimous-category', alternatives: pool.filter((c) => c !== pick).map((c) => c.entry.id).sort() } };
175
179
  }
176
180
  const counts = new Map();
177
181
  for (const c of pool) counts.set(c.decision.category, (counts.get(c.decision.category) ?? 0) + 1);