@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,170 @@
1
+ // Disclosure of what a Haskell taint finding could not see (HS-005.AC03).
2
+ //
3
+ // The taint engine WIDENS across a call it cannot resolve (an unknown function's result is tainted when
4
+ // its arguments are), so a flow through an opaque target is kept rather than silently dropped or called
5
+ // safe. This pass says so: it walks backwards from a finding's sink argument through the assignments
6
+ // that feed it and records every call whose target is unresolved (higher-order parameter, unbound name,
7
+ // typeclass method with no visible instance) or opaque (foreign function), as an `uncertainty` entry
8
+ // with the reason, and marks the finding's resolution as partial. It adds evidence; it never removes a
9
+ // finding or changes its severity.
10
+
11
+ import { sourceInfo } from './haskell-models.js';
12
+
13
+ const OPAQUE = {
14
+ unknown: (c) => ({ kind: 'unresolved-target', detail: `${c.callee}: ${(c.hs && c.hs.reason) || 'unresolved'}` }),
15
+ param: (c) => ({ kind: 'unresolved-target', detail: `${c.callee}: higher-order parameter, the function actually passed is not known here` }),
16
+ ffi: (c) => ({ kind: 'foreign-boundary', detail: `${c.callee}: foreign function, the code behind it is not analysed` }),
17
+ typeclass: (c) => ((c.hs && c.hs.candidates && c.hs.candidates.length) ? null : { kind: 'unresolved-target', detail: `${c.callee}: typeclass method with no visible instance` }),
18
+ };
19
+
20
+ const kids = (e) => {
21
+ const out = [];
22
+ for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') out.push(e[k]);
23
+ for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
24
+ if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
25
+ return out;
26
+ };
27
+
28
+ function* walk(e, depth = 0) {
29
+ if (!e || typeof e !== 'object' || depth > 80) return;
30
+ yield e;
31
+ for (const c of kids(e)) yield* walk(c, depth + 1);
32
+ }
33
+
34
+ function identsOf(e) {
35
+ const out = new Set();
36
+ for (const x of walk(e)) if (x.kind === 'ident' && x.name) out.add(x.name);
37
+ return out;
38
+ }
39
+
40
+ function nodeExprs(n) { return [n.source, n.value, n.cond, ...(n.kind === 'call' ? [{ kind: 'call', callee: n.callee, args: n.args || [], hs: n.hs }] : [])].filter(Boolean); }
41
+
42
+ /** @returns {Array<{kind:string, detail:string}>} */
43
+ export function unresolvedTargetsFor(fn, finding) {
44
+ const nodes = Object.values(fn.cfg.nodes);
45
+ // the sink call at the finding's line
46
+ let sink = null;
47
+ for (const n of nodes) {
48
+ for (const root of nodeExprs(n)) {
49
+ for (const e of walk(root)) if (!sink && e.kind === 'call' && e.callee === finding.callee && (e.line === finding.line || n.line === finding.line)) sink = e;
50
+ }
51
+ }
52
+ if (!sink) return [];
53
+ const arg = (sink.args || [])[Number.isInteger(finding.argIndex) ? finding.argIndex : 0];
54
+ if (!arg) return [];
55
+ const found = new Map();
56
+ const seen = new Set();
57
+ const stack = [arg];
58
+ let guard = 0;
59
+ while (stack.length && guard++ < 400) {
60
+ const e = stack.pop();
61
+ for (const x of walk(e)) {
62
+ if (x.kind === 'call' && x.hs && OPAQUE[x.hs.status]) {
63
+ const d = OPAQUE[x.hs.status](x);
64
+ if (d) found.set(`${d.kind}|${d.detail}`, d);
65
+ }
66
+ }
67
+ for (const name of identsOf(e)) {
68
+ const key = name.replace(/\.[^.]*$/, '');
69
+ if (seen.has(key)) continue;
70
+ seen.add(key);
71
+ for (const n of nodes) {
72
+ if (n.kind === 'assign' && n.source && typeof n.target === 'string' && (n.target === key || n.target.startsWith(`${key}.`))) stack.push(n.source);
73
+ }
74
+ }
75
+ }
76
+ return [...found.values()];
77
+ }
78
+
79
+ export function annotateUnresolvedTargets(findings, perFile) {
80
+ let n = 0;
81
+ for (const f of findings || []) {
82
+ if (!f || f.parser !== 'IR-TAINT' || !f._funcQid || !f.callee) continue;
83
+ const ir = perFile && perFile[f.file];
84
+ const fn = ir && ir.functions.find((x) => x.qid === f._funcQid);
85
+ if (!fn) continue;
86
+ let un;
87
+ try { un = unresolvedTargetsFor(fn, f); } catch { continue; }
88
+ if (!un.length) continue;
89
+ f.uncertainty = [...(f.uncertainty || []), ...un];
90
+ f.resolutionStatus = 'partial';
91
+ n++;
92
+ }
93
+ return n;
94
+ }
95
+
96
+ // ── evidence chain ────────────────────────────────────────────────────────────
97
+ // The shared engine attributes a source only when a variable is assigned DIRECTLY from it, so a flow
98
+ // that passes through a record field or an intermediate binding arrives with an empty chain. The
99
+ // chain is rebuilt here from the IR instead: walk backwards from the sink argument through the
100
+ // assignments that feed it until a modelled source is reached, then report source -> ... -> sink in
101
+ // program order. Cross-function flows keep whatever the engine already recorded.
102
+ export function buildChain(fn, finding) {
103
+ const nodes = Object.entries(fn.cfg.nodes).map(([id, n]) => ({ id, ...n })).sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
104
+ let sink = null; let sinkNodeIdx = -1;
105
+ nodes.forEach((n, i) => {
106
+ for (const root of nodeExprs(n)) for (const e of walk(root)) if (!sink && e.kind === 'call' && e.callee === finding.callee && (e.line === finding.line || n.line === finding.line)) { sink = e; sinkNodeIdx = i; }
107
+ });
108
+ if (!sink) return null;
109
+ const arg = (sink.args || [])[Number.isInteger(finding.argIndex) ? finding.argIndex : 0];
110
+ if (!arg) return null;
111
+ const steps = [];
112
+ const seen = new Set();
113
+ let source = null;
114
+ const queue = [{ expr: arg, before: sinkNodeIdx }];
115
+ let guard = 0;
116
+ while (queue.length && !source && guard++ < 300) {
117
+ const { expr, before } = queue.shift();
118
+ for (const x of walk(expr)) {
119
+ if (x.kind === 'call' && typeof x.callee === 'string') { const info = sourceInfo(x.callee); if (info) { source = { line: x.line, label: info.label, provenance: info.provenance }; break; } }
120
+ }
121
+ if (source) break;
122
+ for (const name of identsOf(expr)) {
123
+ const root = name.replace(/\.[^.]*$/, '');
124
+ for (let i = Math.min(before, nodes.length) - 1; i >= 0; i--) {
125
+ const n = nodes[i];
126
+ if (n.kind !== 'assign' || typeof n.target !== 'string' || !n.source) continue;
127
+ if (!(n.target === name || n.target === root || n.target.startsWith(`${root}.`))) continue;
128
+ const key = `${n.id}`;
129
+ if (seen.has(key)) continue;
130
+ seen.add(key);
131
+ steps.push({ line: n.line, label: `${n.target} = ...` });
132
+ queue.push({ expr: n.source, before: i });
133
+ }
134
+ }
135
+ }
136
+ if (!source) return null;
137
+ const ordered = steps.filter((s) => s.line !== source.line || s.label).sort((a, b) => a.line - b.line);
138
+ return [{ line: source.line, label: source.label, provenance: source.provenance }, ...ordered.filter((s) => s.line > source.line)];
139
+ }
140
+
141
+ /** The last hop of an evidence chain is the sink itself, so a SARIF code flow ends where the finding is reported. */
142
+ function appendSinkStep(f) {
143
+ if (!Array.isArray(f.chain) || !f.chain.length) return;
144
+ const sinkFile = (f.sink && f.sink.file) || f.file;
145
+ const sinkLine = (f.sink && f.sink.line) || f.line;
146
+ const last = f.chain[f.chain.length - 1];
147
+ if (!Number.isInteger(sinkLine) || (last && last.kind === 'sink')) return;
148
+ if (last && last.line === sinkLine && (last.file || f.file) === sinkFile && !/^source|standard input|stdin|argument|environment/i.test(String(last.label || ''))) { last.kind = 'sink'; return; }
149
+ f.chain.push({ file: sinkFile, line: sinkLine, label: `sink: ${String(f.callee || '').replace(/^.*\./, '') || 'call'}`, kind: 'sink' });
150
+ }
151
+
152
+ export function attachEvidenceChains(findings, perFile) {
153
+ let n = 0;
154
+ for (const f of findings || []) {
155
+ if (!f || f.parser !== 'IR-TAINT' || !f._funcQid || !f.callee) continue;
156
+ if (Array.isArray(f.chain) && f.chain.length) { appendSinkStep(f); continue; }
157
+ const ir = perFile && perFile[f.file];
158
+ const fn = ir && ir.functions.find((x) => x.qid === f._funcQid);
159
+ if (!fn) continue;
160
+ let chain;
161
+ try { chain = buildChain(fn, f); } catch { continue; }
162
+ if (!chain || !chain.length) continue;
163
+ f.chain = chain.map((c) => ({ file: f.file, line: c.line, label: c.label, ...(c.provenance ? { provenance: c.provenance } : {}) }));
164
+ appendSinkStep(f);
165
+ f.source = { file: f.file, line: chain[0].line, label: chain[0].label };
166
+ if (chain[0].provenance) f.sourceProvenance = chain[0].provenance;
167
+ n++;
168
+ }
169
+ return n;
170
+ }
@@ -0,0 +1,44 @@
1
+ // Language metadata for findings the taint engine emits over Haskell IR. The engine is
2
+ // language-neutral; this adds the additive contract fields (language, capability, analysisKind,
3
+ // evidenceKind, originalLocation, model version) and a stable family from the CWE, so a Haskell
4
+ // finding is distinguishable, filterable and compliance-mappable without a second finding schema.
5
+
6
+ import { HS_MODEL_VERSION } from './haskell-models.js';
7
+
8
+ const HS_FILE = /\.(?:hs|lhs|hsc)$/i;
9
+
10
+ // CWE -> the family vocabulary the compliance maps and reports already use.
11
+ export const HS_FAMILY_BY_CWE = Object.freeze({
12
+ 'CWE-78': 'command-injection',
13
+ 'CWE-88': 'argument-injection',
14
+ 'CWE-22': 'path-traversal',
15
+ 'CWE-89': 'sql-injection',
16
+ 'CWE-918': 'ssrf',
17
+ 'CWE-79': 'xss',
18
+ });
19
+
20
+ export const isHaskellFile = (file) => typeof file === 'string' && HS_FILE.test(file);
21
+
22
+ /**
23
+ * Mutates and returns the array: stamps every IR-TAINT finding located in a Haskell file.
24
+ * `fileContents` (path -> text) supplies the original source line as the finding's snippet. Without
25
+ * it two findings of one rule in one file share a stable id (the id is derived from rule + snippet +
26
+ * path shape), which silently merges their triage state and sanitizer evidence.
27
+ */
28
+ export function annotateHaskellFindings(findings, fileContents = null) {
29
+ for (const f of findings || []) {
30
+ if (!f || !isHaskellFile(f.file)) continue;
31
+ if (!f.snippet && fileContents && typeof fileContents[f.file] === 'string' && Number.isInteger(f.line) && f.line >= 1) {
32
+ const text = fileContents[f.file].split('\n')[f.line - 1];
33
+ if (typeof text === 'string') f.snippet = text.trim().slice(0, 240);
34
+ }
35
+ f.language = 'haskell';
36
+ f.capability = f.capability || 'taint';
37
+ f.analysisKind = f.analysisKind || 'application';
38
+ f.evidenceKind = f.evidenceKind || 'source';
39
+ f.modelVersion = HS_MODEL_VERSION;
40
+ if (HS_FAMILY_BY_CWE[f.cwe]) f.family = HS_FAMILY_BY_CWE[f.cwe];
41
+ if (Number.isInteger(f.line) && f.line >= 1) f.originalLocation = { file: f.file, line: f.line, column: 0 };
42
+ }
43
+ return findings;
44
+ }
@@ -0,0 +1,409 @@
1
+ // Haskell remediation and dependency upgrades (HS-010).
2
+ //
3
+ // Deterministic patches exist only for patterns where the rewrite is provably the same program minus the
4
+ // vulnerability (SQL parameterization, argv instead of a shell string, contextual HTML escaping, a stronger
5
+ // hash, redacting a sensitive log argument). Everything else returns `{ok:false, proposal:'model-assisted'}`:
6
+ // this module never invents a rewrite it cannot justify.
7
+ //
8
+ // A patch is APPLIED only after it passes every gate: syntax (no new parse errors), a rescan in which the
9
+ // original finding is gone and nothing at medium or above is new, and, strictly opt-in, an isolated compile.
10
+ // Default operation never starts GHC, cabal, stack or Setup.hs.
11
+ //
12
+ // Dependency proposals edit the user's declared constraint at the declaring line (scope, flags and
13
+ // conditionals untouched). Without a supported RESOLVED verification they are `unverified`, never `fixed`,
14
+ // and a generated plan is never edited.
15
+
16
+ import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
18
+ import { tmpdir } from 'node:os';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { parseHaskell } from './haskell-parser.js';
21
+ import { SENSITIVE } from './haskell-security-rules.js';
22
+ import { compareVersions, parseVersion } from './haskell-manifests.js';
23
+ import { runFixLifecycle, unifiedDiff, writeWithBackup, undoFix } from './fix-lifecycle.js';
24
+
25
+ export const HS_FIX_VERSION = 'haskell-fix/1';
26
+ export const LABELS = Object.freeze(['FULL', 'MITIGATION', 'WORKAROUND']);
27
+
28
+ // ── small expression reader ──────────────────────────────────────────────────
29
+ const STRING = /^"(?:[^"\\]|\\.)*"$/;
30
+ const IDENT = /^[a-z_][\w']*$/;
31
+
32
+ /** End index (exclusive) of the atom starting at i: identifier, string literal or balanced group. */
33
+ function atomEnd(s, i) {
34
+ while (s[i] === ' ') i++;
35
+ const c = s[i];
36
+ if (c === '"') { let j = i + 1; while (j < s.length && (s[j] !== '"' || s[j - 1] === '\\')) j++; return j < s.length ? j + 1 : -1; }
37
+ if (c === '(' || c === '[') {
38
+ const close = c === '(' ? ')' : ']'; let depth = 0;
39
+ for (let j = i; j < s.length; j++) {
40
+ if (s[j] === '"') { j = atomEnd(s, j) - 1; if (j < 0) return -1; continue; }
41
+ if (s[j] === c) depth++; else if (s[j] === close && --depth === 0) return j + 1;
42
+ }
43
+ return -1;
44
+ }
45
+ const m = /^[\w'.]+/.exec(s.slice(i));
46
+ return m ? i + m[0].length : -1;
47
+ }
48
+ const trimParens = (e) => { let s = e.trim(); while (s[0] === '(' && atomEnd(s, 0) === s.length) s = s.slice(1, -1).trim(); return s; };
49
+
50
+ function topLevelSplit(s, sep) {
51
+ const parts = []; let depth = 0; let start = 0;
52
+ for (let i = 0; i < s.length; i++) {
53
+ const c = s[i];
54
+ if (c === '"') { const e = atomEnd(s, i); if (e < 0) return null; i = e - 1; continue; }
55
+ if (c === '(' || c === '[') depth++;
56
+ else if (c === ')' || c === ']') depth--;
57
+ else if (depth === 0 && s.startsWith(sep, i)) { parts.push(s.slice(start, i)); start = i + sep.length; i += sep.length - 1; }
58
+ }
59
+ parts.push(s.slice(start));
60
+ return parts;
61
+ }
62
+ const litValue = (t) => t.slice(1, -1);
63
+
64
+ /** `"a" ++ n ++ "b"` -> [{lit:'a'},{var:'n'},{lit:'b'}], or null for anything we cannot prove simple. */
65
+ function concatParts(expr) {
66
+ const e = trimParens(expr);
67
+ const raw = topLevelSplit(e, '++');
68
+ if (!raw) return null;
69
+ const out = [];
70
+ for (const r of raw) {
71
+ const t = trimParens(r);
72
+ if (STRING.test(t)) out.push({ lit: litValue(t) });
73
+ else if (IDENT.test(t)) out.push({ var: t });
74
+ else return null;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function unwrapQueryExpr(expr) {
80
+ let e = trimParens(expr);
81
+ for (;;) {
82
+ const m = /^(?:fromString|Query)\b\s*(?:\$\s*)?([\s\S]+)$/.exec(e);
83
+ if (!m) return e;
84
+ e = trimParens(m[1]);
85
+ }
86
+ }
87
+
88
+ // ── imports ──────────────────────────────────────────────────────────────────
89
+ function importInfo(text, module) {
90
+ const re = new RegExp(`^import\\s+(qualified\\s+)?${module.replace(/\./g, '\\.')}(?:\\s+qualified)?(?:\\s+as\\s+(\\w+))?\\s*(?:\\((.*)\\))?\\s*(hiding\\b.*)?$`, 'm');
91
+ const m = re.exec(text);
92
+ if (!m) return null;
93
+ return { index: m.index, line: m[0], qualified: !!(m[1] || /\bqualified\b/.test(m[0])), alias: m[2] || null, items: m[3] === undefined ? null : m[3].split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean), hiding: !!m[4] };
94
+ }
95
+ /** Make `names` usable from `module`; returns {text, prefix} or null when the import shape forbids it. */
96
+ function ensureImport(text, module, names) {
97
+ const info = importInfo(text, module);
98
+ if (!info) {
99
+ const lines = text.split('\n');
100
+ let at = -1; for (let i = 0; i < lines.length; i++) if (/^import\s/.test(lines[i])) at = i;
101
+ if (at < 0) { const mi = lines.findIndex((l) => /\bwhere\b/.test(l)); at = mi < 0 ? 0 : mi; }
102
+ lines.splice(at + 1, 0, `import ${module} (${names.join(', ')})`);
103
+ return { text: lines.join('\n'), prefix: '' };
104
+ }
105
+ if (info.hiding) return null;
106
+ if (info.qualified) return { text, prefix: `${info.alias || module}.` };
107
+ if (info.items === null) return { text, prefix: '' };
108
+ const have = new Set(info.items.map((i) => i.replace(/\(.*$/, '').trim()));
109
+ const missing = names.filter((n) => !have.has(n.replace(/\(.*$/, '')));
110
+ if (!missing.length) return { text, prefix: '' };
111
+ const next = info.line.replace(/\((.*)\)/, (_, inner) => `(${[...info.items, ...missing].join(', ')})`);
112
+ return { text: text.replace(info.line, next), prefix: '' };
113
+ }
114
+ const hasPragma = (text, name) => new RegExp(`\\{-#\\s*LANGUAGE[^#]*\\b${name}\\b`).test(text);
115
+
116
+ // ── deterministic fixers ─────────────────────────────────────────────────────
117
+ const lineBounds = (text, line) => { const ls = text.split('\n'); let off = 0; for (let i = 0; i < line - 1; i++) off += ls[i].length + 1; return { start: off, end: off + (ls[line - 1] || '').length, text: ls[line - 1] ?? '' }; };
118
+ const splice = (text, a, b, repl) => text.slice(0, a) + repl + text.slice(b);
119
+
120
+ /** callee atom position inside a line: optional qualifier + one of `names`. */
121
+ function findCall(lineText, names) {
122
+ const re = new RegExp(`(?<![\\w'.])((?:[A-Z][\\w']*\\.)?)(${names.join('|')})(?![\\w'])`);
123
+ const m = re.exec(lineText);
124
+ return m ? { prefix: m[1], name: m[2], index: m.index, after: m.index + m[0].length } : null;
125
+ }
126
+
127
+ function fixSql(text, f) {
128
+ const lb = lineBounds(text, f.line);
129
+ const call = findCall(lb.text, ['execute_', 'query_']);
130
+ if (!call) return null;
131
+ const connEnd = atomEnd(lb.text, call.after); if (connEnd < 0) return null;
132
+ const qStart = (() => { let i = connEnd; while (lb.text[i] === ' ') i++; return i; })();
133
+ const qEnd = atomEnd(lb.text, qStart); if (qEnd < 0) return null;
134
+ const parts = concatParts(unwrapQueryExpr(lb.text.slice(qStart, qEnd)));
135
+ if (!parts || !parts.some((p) => p.var)) return null;
136
+ let sql = ''; const params = [];
137
+ for (let i = 0; i < parts.length; i++) {
138
+ const p = parts[i];
139
+ if (p.lit !== undefined) { sql += p.lit; continue; }
140
+ const before = parts[i - 1], after = parts[i + 1];
141
+ const q = before && before.lit !== undefined && /'$/.test(before.lit) && after && after.lit !== undefined && /^'/.test(after.lit);
142
+ const halfQuoted = (before && before.lit !== undefined && /'$/.test(before.lit)) !== (after && after.lit !== undefined && /^'/.test(after.lit));
143
+ if (halfQuoted) return null; // an unbalanced quote around the value: not a pattern we can prove
144
+ if (q) { sql = sql.slice(0, -1); parts[i + 1] = { lit: after.lit.slice(1) }; }
145
+ sql += '?'; params.push(p.var);
146
+ }
147
+ const imp = ensureImport(text, 'Database.PostgreSQL.Simple', ['execute', 'query', 'Only']);
148
+ if (!imp) return null;
149
+ const only = `${call.prefix || imp.prefix}Only`;
150
+ const tuple = params.length === 1 ? `(${only} ${params[0]})` : `(${params.join(', ')})`;
151
+ const fn = call.name === 'execute_' ? 'execute' : 'query';
152
+ let lit = `"${sql}"`; let t2 = imp.text;
153
+ if (!hasPragma(text, 'OverloadedStrings')) {
154
+ const fs = ensureImport(imp.text, 'Data.String', ['fromString']); if (!fs) return null;
155
+ t2 = fs.text; lit = `(${fs.prefix}fromString ${lit})`;
156
+ }
157
+ // splice on the ORIGINAL text first, then re-apply import edits via a line-count-stable approach
158
+ const replaced = lb.text.slice(0, call.index) + `${call.prefix}${fn}` + lb.text.slice(call.after, connEnd) + ` ${lit} ${tuple}` + lb.text.slice(qEnd);
159
+ const body = splice(text, lb.start, lb.end, replaced);
160
+ const next = applyImportDelta(text, t2, body);
161
+ return { ruleId: 'hs-sql-parameterize', label: 'FULL', text: next, explanation: `Moved ${params.length} interpolated value(s) out of the SQL text into ${fn} parameters.`, behavior: { kind: 'sql', sql, params } };
162
+ }
163
+ /** Import edits were computed on `orig`; carry them over to `body`, which differs only on one line. */
164
+ function applyImportDelta(orig, withImports, body) {
165
+ if (orig === withImports) return body;
166
+ const o = orig.split('\n'), w = withImports.split('\n'), b = body.split('\n');
167
+ const origImports = o.filter((l) => /^import\s/.test(l)), newImports = w.filter((l) => /^import\s/.test(l));
168
+ let out = b.slice();
169
+ // replace changed import lines in place, insert genuinely new ones after the last import
170
+ for (const l of origImports) if (!newImports.includes(l)) { const ch = newImports.find((n) => !origImports.includes(n) && n.split('(')[0].trim() === l.split('(')[0].trim()); if (ch) out = out.map((x) => (x === l ? ch : x)); }
171
+ const added = newImports.filter((n) => !origImports.includes(n) && !origImports.some((l) => l.split('(')[0].trim() === n.split('(')[0].trim()));
172
+ if (added.length) { let at = -1; out.forEach((l, i) => { if (/^import\s/.test(l)) at = i; }); out.splice(at + 1, 0, ...added); }
173
+ return out.join('\n');
174
+ }
175
+
176
+ function fixProcess(text, f) {
177
+ const lb = lineBounds(text, f.line);
178
+ const call = findCall(lb.text, ['callCommand', 'system']);
179
+ if (!call) return null;
180
+ let aStart = call.after; while (lb.text[aStart] === ' ') aStart++;
181
+ let aEnd;
182
+ if (lb.text[aStart] === '$') { aStart++; while (lb.text[aStart] === ' ') aStart++; aEnd = lb.text.length; }
183
+ else aEnd = atomEnd(lb.text, aStart);
184
+ if (aEnd < 0) return null;
185
+ const parts = concatParts(lb.text.slice(aStart, aEnd));
186
+ if (!parts || !parts.some((p) => p.var)) return null; // a bare variable: no provable argv split
187
+ // tokenise into words; a word is a list of pieces
188
+ const words = []; let cur = [];
189
+ const flush = () => { if (cur.length) { words.push(cur); cur = []; } };
190
+ for (const p of parts) {
191
+ if (p.var) { cur.push(p); continue; }
192
+ const segs = p.lit.split(/(\s+)/);
193
+ for (const s of segs) { if (!s) continue; if (/^\s+$/.test(s)) flush(); else cur.push({ lit: s }); }
194
+ }
195
+ flush();
196
+ if (!words.length || words[0].some((p) => p.var)) return null; // the program itself must be a literal
197
+ if (/[|&;<>`$*?()\\]/.test(words[0].map((p) => p.lit).join('')) || words.slice(1).some((w) => w.every((p) => p.lit !== undefined) && /[|&;<>`$*?()]/.test(w.map((p) => p.lit).join('')))) return null; // shell syntax in the literal: not an argv
198
+ const prog = words[0].map((p) => p.lit).join('');
199
+ const argExpr = (w) => (w.length === 1 && w[0].lit !== undefined ? `"${w[0].lit}"` : (w.length === 1 ? w[0].var : `(${w.map((p) => (p.lit !== undefined ? `"${p.lit}"` : p.var)).join(' ++ ')})`));
200
+ let args = words.slice(1);
201
+ // End option parsing before the first value-bearing argument, but only when no earlier literal is itself an
202
+ // option (it might take a value, and "--" would then be consumed as that value). Otherwise leave it to the gate.
203
+ const firstVar = args.findIndex((w) => w.some((p) => p.var));
204
+ const optionBefore = args.slice(0, Math.max(firstVar, 0)).some((w) => w[0].lit !== undefined && /^-/.test(w[0].lit));
205
+ const terminated = firstVar >= 0 && !optionBefore && !(args[firstVar][0].lit !== undefined);
206
+ if (terminated) args = [[{ lit: '--' }], ...args];
207
+ const whole = args.every((w) => w.length === 1);
208
+ const fn = call.name === 'callCommand' ? 'callProcess' : 'rawSystem';
209
+ const imp = ensureImport(text, 'System.Process', [fn]); if (!imp) return null;
210
+ const repl = `${call.prefix || imp.prefix}${fn} "${prog}" [${args.map(argExpr).join(', ')}]`;
211
+ const replaced = lb.text.slice(0, call.index) + repl + lb.text.slice(aEnd);
212
+ const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, replaced));
213
+ return { ruleId: 'hs-process-argv', label: 'MITIGATION', text: next, explanation: `Replaced a shell command string with a direct ${fn} of "${prog}" and an argument list; no shell parses the value${whole ? '' : ' (a value fused into an argument is still passed as data)'}. ${terminated ? 'A "--" now ends option parsing before the value (assumes the program follows that convention).' : 'A value that starts with "-" can still be read as an option by the program: add "--" or validate it.'}`, behavior: { kind: 'process', program: prog, argv: args.map((w) => w.map((p) => (p.lit !== undefined ? { lit: p.lit } : { var: p.var }))) } };
214
+ }
215
+
216
+ function fixHtml(text, f) {
217
+ const lb = lineBounds(text, f.line);
218
+ const m = /(?<![\w'.])((?:[A-Z][\w']*\.)?)preEscapedToHtml(?![\w'])/.exec(lb.text);
219
+ if (!m) return null;
220
+ const imp = ensureImport(text, 'Text.Blaze.Html', ['toHtml']); if (!imp) return null;
221
+ const repl = lb.text.slice(0, m.index) + `${m[1]}toHtml` + lb.text.slice(m.index + m[0].length);
222
+ const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, repl));
223
+ return { ruleId: 'hs-html-escape', label: 'FULL', text: next, explanation: 'preEscapedToHtml trusts its argument as markup; toHtml escapes <, >, &, quotes. Same type, the value is now text.', behavior: { kind: 'html', from: 'preEscapedToHtml', to: 'toHtml' } };
224
+ }
225
+
226
+ function fixWeakHash(text, f) {
227
+ const lb = lineBounds(text, f.line);
228
+ const re = /(?<![\w'.])(MD5|SHA1)(?![\w'.])/g;
229
+ if (!re.test(lb.text)) return null;
230
+ const imp = ensureImport(text, 'Crypto.Hash', ['SHA256(..)']); if (!imp) return null;
231
+ const repl = lb.text.replace(/(?<![\w'.])(MD5|SHA1)(?![\w'.])/g, 'SHA256');
232
+ const next = applyImportDelta(text, imp.text, splice(text, lb.start, lb.end, repl));
233
+ return { ruleId: 'hs-hash-sha256', label: 'FULL', text: next, explanation: 'MD5/SHA-1 replaced by SHA-256. Digests change: anything that stored or compares old digests must be migrated.', behavior: { kind: 'hash', from: ['MD5', 'SHA1'], to: 'SHA256' } };
234
+ }
235
+
236
+ const LOG_CALLS = ['putStrLn', 'putStr', 'hPutStrLn', 'hPutStr', 'print', 'trace', 'traceShow', 'traceShowId', 'logInfoN', 'logDebugN', 'logWarnN', 'logErrorN', 'logInfo', 'logDebug', 'logWarn', 'logError'];
237
+ function fixLogging(text, f) {
238
+ const lb = lineBounds(text, f.line);
239
+ const call = findCall(lb.text, LOG_CALLS);
240
+ if (!call) return null;
241
+ const head = lb.text.slice(0, call.after); let rest = lb.text.slice(call.after);
242
+ let n = 0;
243
+ rest = rest.replace(/(?<![\w'.])(?:show\s+)?((?:[A-Za-z_][\w']*\.)?[a-z_][\w']*)(?![\w'(])/g, (all, id) => {
244
+ const bare = id.replace(/^.*\./, '');
245
+ if (/^(?:show|pack|unpack|stderr|stdout)$/.test(bare) || !SENSITIVE.test(bare)) return all;
246
+ n++; return '"[REDACTED]"';
247
+ });
248
+ if (!n) return null;
249
+ const next = splice(text, lb.start, lb.end, head + rest);
250
+ return { ruleId: 'hs-log-redact', label: 'MITIGATION', text: next, explanation: `Replaced ${n} sensitive log argument(s) with "[REDACTED]". The value is no longer logged; the log line loses that detail.`, behavior: { kind: 'log', redacted: n } };
251
+ }
252
+
253
+ const FIXERS = [
254
+ { id: 'hs-sql-parameterize', applies: (f) => f.cwe === 'CWE-89', run: fixSql },
255
+ { id: 'hs-process-argv', applies: (f) => f.cwe === 'CWE-78', run: fixProcess },
256
+ { id: 'hs-html-escape', applies: (f) => f.cwe === 'CWE-79', run: fixHtml },
257
+ { id: 'hs-hash-sha256', applies: (f) => f.cwe === 'CWE-328' || f.rule === 'hs-weak-hash', run: fixWeakHash },
258
+ { id: 'hs-log-redact', applies: (f) => f.cwe === 'CWE-532' || f.rule === 'hs-sensitive-logging', run: fixLogging },
259
+ ];
260
+ export const SUPPORTED_FIX_IDS = Object.freeze(FIXERS.map((x) => x.id));
261
+
262
+ /**
263
+ * Plan a deterministic fix for one finding. Pure: reads `files`, returns the new text of the one file.
264
+ * @returns {{ok:true,ruleId,label,file,before,after,explanation,behavior}|{ok:false,reason,proposal?}}
265
+ */
266
+ /** Lines inside the bodies of top-level functions of `text` that are called on line `line` (1-based), nearest definition first. */
267
+ function calleeLines(text, line) {
268
+ const lines = text.split('\n');
269
+ const here = lines[line - 1] || '';
270
+ const defs = new Map(); // name -> [first body line, last body line] (1-based)
271
+ const isTop = (l) => /^[a-z_][\w']*\s+.*=|^[a-z_][\w']*\s*=|^[a-z_][\w']*\s*::/.test(l);
272
+ for (let i = 0; i < lines.length; i++) {
273
+ const m = /^([a-z_][\w']*)\b[^:\n]*=/.exec(lines[i]);
274
+ if (!m || /^[a-z_][\w']*\s*::/.test(lines[i])) continue;
275
+ let end = i; while (end + 1 < lines.length && (/^\s+\S/.test(lines[end + 1]) || lines[end + 1].trim() === '') && !isTop(lines[end + 1])) end++;
276
+ if (!defs.has(m[1])) defs.set(m[1], [i + 1, end + 1]);
277
+ }
278
+ const out = [];
279
+ for (const [name, [a, b]] of defs) {
280
+ if (a <= line && line <= b) continue; // the function the finding is in
281
+ if (new RegExp(`(?<![\\w'.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w'])`).test(here)) for (let l = a; l <= b; l++) out.push(l);
282
+ }
283
+ return out;
284
+ }
285
+
286
+ export function planHaskellFix(finding, files) {
287
+ if (!finding || !finding.file || !Number.isInteger(finding.line)) return { ok: false, reason: 'finding has no file and line' };
288
+ const before = files[finding.file];
289
+ if (typeof before !== 'string') return { ok: false, reason: `file ${finding.file} is not in the supplied tree` };
290
+ if (!/\.l?hs$/i.test(finding.file)) return { ok: false, reason: 'not a Haskell source file' };
291
+ if (/\{-#\s*LANGUAGE[^#]*\bCPP\b|^#\s*(?:if|define|include)\b/m.test(before)) return { ok: false, reason: 'the file uses CPP: a rewrite could change a branch that is not visible here', proposal: 'model-assisted' };
292
+ for (const fx of FIXERS) {
293
+ let applies = false; try { applies = fx.applies(finding); } catch { /* ignore */ }
294
+ if (!applies) continue;
295
+ // The finding is reported where the tainted value ENTERS the code that reaches the sink: for a flow through a function of this
296
+ // file that is the call, while the dangerous API call is inside the callee. Try the reported line first, then the bodies of
297
+ // the same-file functions called on it.
298
+ for (const line of [finding.line, ...calleeLines(before, finding.line)]) {
299
+ let r = null; try { r = fx.run(before, { ...finding, line }); } catch { r = null; }
300
+ if (r && r.text && r.text !== before) return { ok: true, ruleId: r.ruleId, label: r.label, file: finding.file, before, after: r.text, explanation: r.explanation, behavior: r.behavior, ...(line !== finding.line ? { fixedAtLine: line } : {}) };
301
+ }
302
+ }
303
+ return { ok: false, reason: 'no deterministic fix is supported for this exact shape', proposal: 'model-assisted' };
304
+ }
305
+
306
+ // ── gates ────────────────────────────────────────────────────────────────────
307
+ const key = (f) => `${f.file}|${f.cwe || ""}`;
308
+
309
+ /** Default rescan: the real scanner over the patched tree, in a scratch directory (never the user's). */
310
+ export async function defaultRescan(files) {
311
+ const { runScan } = await import('../runScan.js');
312
+ const dir = mkdtempSync(join(tmpdir(), 'hs-fix-'));
313
+ try {
314
+ for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
315
+ const r = await runScan(dir, { deep: true });
316
+ // a flow the proof gate discharged (a dominating guard, a sanitizer) is reported but is not still a finding
317
+ return (r.scan.findings || []).filter((f) => !(f.proof && /^proven-/.test(f.proof.verdict)));
318
+ } finally { rmSync(dir, { recursive: true, force: true }); }
319
+ }
320
+
321
+ export function syntaxGate(file, before, after) {
322
+ const a = parseHaskell(before, { file }); const b = parseHaskell(after, { file });
323
+ const nb = (a.errors || []).length, na = (b.errors || []).length;
324
+ return { ok: na <= nb, errorsBefore: nb, errorsAfter: na, detail: na <= nb ? 'no new syntax errors' : `${na - nb} new syntax error(s)` };
325
+ }
326
+
327
+ /** Compile check is OPT-IN. `opts.compile` must be true AND a ghc must exist; otherwise the result says it did not run. */
328
+ export function compileGate(files, opts = {}) {
329
+ if (opts.compile !== true) return { ran: false, ok: null, detail: 'not requested (default static scans never start GHC)' };
330
+ const ghc = opts.ghc || 'ghc';
331
+ const probe = spawnSync(ghc, ['--numeric-version'], { encoding: 'utf8', timeout: 20000 });
332
+ if (probe.error || probe.status !== 0) return { ran: false, ok: null, detail: 'GHC is not available on this host; compile verification was not performed' };
333
+ const dir = mkdtempSync(join(tmpdir(), 'hs-compile-'));
334
+ try {
335
+ for (const [f, t] of Object.entries(files)) if (/\.l?hs$/i.test(f)) { mkdirSync(dirname(join(dir, f)), { recursive: true }); writeFileSync(join(dir, f), t); }
336
+ const hs = Object.keys(files).filter((f) => /\.hs$/i.test(f));
337
+ const r = spawnSync(ghc, ['-fno-code', '-isrc', '-i.', ...hs], { cwd: dir, encoding: 'utf8', timeout: opts.timeoutMs || 120000 });
338
+ return { ran: true, ok: r.status === 0, detail: r.status === 0 ? 'type-checked' : (r.stderr || '').slice(0, 400) };
339
+ } finally { rmSync(dir, { recursive: true, force: true }); }
340
+ }
341
+
342
+ /**
343
+ * Validate (and optionally apply) a fix. Nothing is written unless every gate passes and `apply` is true.
344
+ * @param {object} finding
345
+ * @param {{files: Record<string,string>, root?: string, apply?: boolean, rescan?: Function, compile?: boolean, requireCompile?: boolean, behaviorCheck?: Function}} o
346
+ */
347
+ export async function validateHaskellFix(finding, o) {
348
+ const plan = planHaskellFix(finding, o.files);
349
+ if (!plan.ok) return { status: 'unsupported', applied: false, ...plan };
350
+ return runFixLifecycle({
351
+ plan, files: o.files, finding, matchKey: key, rescan: o.rescan || defaultRescan,
352
+ syntax: (p) => syntaxGate(p.file, p.before, p.after), behaviorCheck: o.behaviorCheck, witness: o.witness, requireWitness: o.requireWitness,
353
+ compile: (patched) => compileGate(patched, o), requireCompile: o.requireCompile, apply: o.apply, root: o.root,
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Verify a patch SUPPLIED BY SOMEONE ELSE (a user, a model) through the same gates as a deterministic fix: path, syntax,
359
+ * the original finding gone, no new medium-or-higher finding. Nothing is written. `proposal` is the new text of `file`.
360
+ */
361
+ export async function verifyHaskellProposal(finding, { files, file, proposal, rescan, compile, requireCompile }) {
362
+ const before = files[file];
363
+ const plan = { file, before: typeof before === 'string' ? before : '', after: proposal, label: 'WORKAROUND' };
364
+ return runFixLifecycle({
365
+ plan, files, finding, matchKey: key, rescan: rescan || defaultRescan, syntax: (p) => syntaxGate(p.file, p.before, p.after),
366
+ compile: (patched) => compileGate(patched, { compile }), requireCompile, apply: false,
367
+ });
368
+ }
369
+
370
+ export { unifiedDiff, writeWithBackup, undoFix };
371
+
372
+ // ── dependency upgrades ──────────────────────────────────────────────────────
373
+ const GENERATED = /(?:^|\/)(?:plan\.json|stack\.yaml\.lock|cabal\.project\.local|package-lock\.json)$|dist-newstyle\//;
374
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
375
+
376
+ /**
377
+ * Propose an upgrade of ONE dependency to a version that fixes an advisory, by editing the declaring line.
378
+ * `verifyResolved(files)` is the only way the proposal can become `fixed`: it must return
379
+ * `{ok:true, resolvedVersion}` from a supported resolver run. With none, the proposal is `unverified`.
380
+ */
381
+ export function planHaskellUpgrade(finding, files, { verifyResolved = null } = {}) {
382
+ const fail = (status, reason, extra = {}) => ({ ok: false, status, reason, ...extra });
383
+ const name = finding.name; const fixed = (finding.fixedIn || []).filter((v) => parseVersion(v)).sort(compareVersions)[0];
384
+ if (!name || !fixed) return fail('blocked', finding.unfixed ? `no fixed version of ${name} is published` : 'the advisory names no fixed version');
385
+ if (GENERATED.test(finding.file || '')) return fail('blocked', 'a generated plan/lock is evidence, not a source of truth: edit the declared constraint and re-resolve');
386
+ const text = files[finding.file];
387
+ if (typeof text !== 'string' || !Number.isInteger(finding.line)) return fail('blocked', 'the declaring line is unknown');
388
+ const lb = lineBounds(text, finding.line);
389
+ const re = new RegExp(`(?<![\\w-])(${escapeRe(name)})(?![\\w-])(\\s*)((?:\\^>=|>=|<=|==|<|>)[^,\\n]*)?`);
390
+ const m = re.exec(lb.text);
391
+ if (!m) return fail('blocked', `could not locate ${name} on line ${finding.line} of ${finding.file}`);
392
+ const old = (m[3] || '').trim();
393
+ let range;
394
+ if (/^==\s*\d/.test(old) && !/\*/.test(old)) range = `== ${fixed}`;
395
+ else {
396
+ const upper = /<\s*(\d+(?:\.\d+)*)/.exec(old);
397
+ if (upper && compareVersions(fixed, upper[1]) >= 0) return fail('blocked', `the fix ${fixed} is outside the declared upper bound < ${upper[1]}; the bound itself must be reviewed`);
398
+ range = upper ? `>= ${fixed} && < ${upper[1]}` : `>= ${fixed}`;
399
+ }
400
+ const lead = lb.text.slice(0, m.index); const trail = lb.text.slice(m.index + m[0].length);
401
+ const edited = `${lead}${m[1]} ${range}${trail}`;
402
+ const next = splice(text, lb.start, lb.end, edited);
403
+ const patched = { ...files, [finding.file]: next };
404
+ const base = { ok: true, file: finding.file, line: finding.line, before: text, after: next, from: old || '(unbounded)', to: range, scopePreserved: true, preview: unifiedDiff(finding.file, text, next) };
405
+ if (!verifyResolved) return { ...base, status: 'unverified', note: 'constraints-only edit: no resolved verification was supplied, so this is NOT a confirmed fix' };
406
+ let v; try { v = verifyResolved(patched); } catch (e) { v = { ok: false, detail: e.message }; }
407
+ if (v && v.ok && v.resolvedVersion && compareVersions(v.resolvedVersion, fixed) >= 0) return { ...base, status: 'fixed', resolvedVersion: v.resolvedVersion };
408
+ return { ...base, status: 'unverified', note: `resolved verification did not confirm a fixed version${v && v.detail ? `: ${v.detail}` : ''}` };
409
+ }