@clear-capabilities/agentic-security-scanner 0.158.0 → 0.160.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.
- package/CHANGELOG.md +39 -0
- package/dist/1919.index.js +5 -2
- package/dist/2862.index.js +29 -9
- package/dist/3531.index.js +1 -1
- package/dist/4171.index.js +4 -4
- package/dist/{8551.index.js → 7033.index.js} +415 -22
- package/dist/9560.index.js +3 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +11 -6
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +48 -1
- package/src/language/assurance.js +21 -4
- package/src/language/context.js +2 -0
- package/src/language/cvss.js +91 -0
- package/src/language/fix-lifecycle.js +114 -21
- package/src/language/haskell-adapter.js +4 -3
- package/src/language/haskell-advisory-feed.js +15 -0
- package/src/language/haskell-boundaries.js +220 -0
- package/src/language/haskell-cpp.js +460 -0
- package/src/language/haskell-ir.js +6 -3
- package/src/language/haskell-models.js +4 -3
- package/src/language/haskell-parser.js +122 -38
- package/src/language/haskell-sca.js +10 -6
- package/src/language/haskell-supply.js +42 -8
- package/src/language/haskell-web.js +128 -23
- package/src/language/nix-advisory-feed.js +275 -0
- package/src/language/nix-fix.js +162 -24
- package/src/language/nix-ir.js +16 -12
- package/src/language/nix-sca.js +71 -12
- package/src/language/nixos-eval-forms.js +162 -0
- package/src/language/nixos-module-resolver.js +346 -51
- package/src/language/resolved-pass.js +57 -3
- package/src/lsp/server.js +5 -2
- package/src/mcp/tools.js +3 -1
- package/src/posture/fix-history.js +30 -3
- package/src/report/index.js +4 -1
- package/dist/1310.index.js +0 -3161
- package/dist/2376.index.js +0 -3151
- package/dist/3526.index.js +0 -1019
- package/dist/7310.index.js +0 -520
- package/dist/8520.index.js +0 -13
- package/dist/9091.index.js +0 -3135
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// * conditions: `mkIf`, `optionalAttrs`, `if/else`, judged by a small evaluator
|
|
11
11
|
// (booleans, strings, ints, `!`, `&&`, `||`, `->`, `==`, `!=`, references to
|
|
12
12
|
// `config.<option>` and let-aliases of them, `pkgs.stdenv.isLinux`-style platform
|
|
13
|
-
// flags from `target.system`, and function arguments supplied in `target.args`)
|
|
13
|
+
// flags from `target.system`, and function arguments supplied in `target.args`),
|
|
14
|
+
// widened to let/with/if, lambdas and a closed allow-list of library functions
|
|
15
|
+
// (see nixos-eval-forms.js and docs/guides/nix-nixos.md "What the evaluator runs").
|
|
14
16
|
// Anything else is UNKNOWN, and an unknown condition keeps its definition
|
|
15
17
|
// "conditional", it is never assumed true or false;
|
|
16
18
|
// * list options merge; scalar options with equal-priority different values are a
|
|
@@ -33,10 +35,13 @@ import * as path from 'node:path';
|
|
|
33
35
|
import { parseNix, spanText } from './nix-parser.js';
|
|
34
36
|
import { analyzeNix, buildNixImportGraph } from './nix-ir.js';
|
|
35
37
|
import { defaultCatalog, normalizeRelease } from './nixos-option-catalog.js';
|
|
38
|
+
import { Closure, Builtin, Ns, NOT_STATIC, isAttrs, hasOwn, setAttr, isPlain, deepEq, nsLookup, isGlobalName, globalValue } from './nixos-eval-forms.js';
|
|
36
39
|
|
|
37
40
|
export const DEFAULT_RESOLVE_BUDGETS = Object.freeze({
|
|
38
41
|
maxEvaluations: 20_000,
|
|
39
42
|
maxEvalDepth: 24,
|
|
43
|
+
maxExprDepth: 200,
|
|
44
|
+
maxValueSize: 4096,
|
|
40
45
|
maxDefinitionsPerOption: 500,
|
|
41
46
|
maxFiles: 200,
|
|
42
47
|
});
|
|
@@ -45,6 +50,7 @@ const PRIORITY = { mkForce: 50, mkVMOverride: 10, mkDefault: 1000, mkOptionDefau
|
|
|
45
50
|
const SKIP_TOP = new Set(['options', 'meta', 'imports', '_module', 'inputs', 'outputs', 'description', 'disabledModules']);
|
|
46
51
|
const BLOCKER_KINDS = new Set(['dynamic-import', 'missing-import', 'import-cycle', 'syntax-error', 'budget-exceeded', 'import-budget', 'import-depth', 'ir-budget', 'missing-grammar', 'parse-failed', 'opaque-config', 'flake-modules-not-followed']);
|
|
47
52
|
const UNK = Object.freeze({ known: false });
|
|
53
|
+
const UNK_WITH = Object.freeze({ unknownWith: true });
|
|
48
54
|
const known = (value) => ({ known: true, value });
|
|
49
55
|
|
|
50
56
|
class Cap extends Error {
|
|
@@ -82,6 +88,10 @@ class Resolver {
|
|
|
82
88
|
this.stack = new Set();
|
|
83
89
|
this.parseCache = new Map();
|
|
84
90
|
this.steps = 0;
|
|
91
|
+
this.exprDepth = 0;
|
|
92
|
+
this.aliasBusy = new Set();
|
|
93
|
+
this.fileInfoCache = new Map();
|
|
94
|
+
this.ctx = null;
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
gap(g) { this.unresolved.push(g); }
|
|
@@ -185,7 +195,7 @@ class Resolver {
|
|
|
185
195
|
if (cat.renames[pathText]) { viaRename = { from: pathText, to: cat.renames[pathText] }; pathText = cat.renames[pathText]; p = pathText.split('.'); }
|
|
186
196
|
const def = {
|
|
187
197
|
id: id++, namespace: ns, scope, path: p, pathText, file, span: b.span, valueSpan: b.valueSpan, summary: b.value,
|
|
188
|
-
priorityName: b.priority || 'plain', priorityArg: b.priorityArg || null, conditions: b.conditions || [], merge: b.merge || null, viaRename,
|
|
198
|
+
withs: b.withs || [], evalSpan: b.evalSpan || null, evalWiths: b.evalWiths || null, inRec: !!b.inRec, priorityName: b.priority || 'plain', priorityArg: b.priorityArg || null, conditions: b.conditions || [], merge: b.merge || null, viaRename,
|
|
189
199
|
};
|
|
190
200
|
const key = this.key(ns, scope, pathText);
|
|
191
201
|
if (!this.defs.has(key)) this.defs.set(key, []);
|
|
@@ -245,12 +255,101 @@ class Resolver {
|
|
|
245
255
|
return { known: false, reason: 'default-differs-by-release-and-release-unknown' };
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
// ── scopes ──
|
|
259
|
+
//
|
|
260
|
+
// Local scope chain (`env.sc`): `{ up, names: Map<name, Cell>|null, withv }`. A `let` or lambda scope has `names`; a `with`
|
|
261
|
+
// scope has `withv` (an attribute set value, a namespace, or UNK_WITH when its contents are not known). Lookup follows Nix's
|
|
262
|
+
// static rule: a lexical binding anywhere in scope wins over every `with`, and among `with`s the innermost wins.
|
|
263
|
+
|
|
264
|
+
scopeOf(env, names, withv) { return { up: env.sc || null, names: names || null, withv }; }
|
|
265
|
+
|
|
266
|
+
cellValue(cell) {
|
|
267
|
+
if (cell.res) return cell.res;
|
|
268
|
+
if (cell.busy) return UNK;
|
|
269
|
+
cell.busy = true;
|
|
270
|
+
try { cell.res = this.evalExpr(cell.expr, cell.env); } finally { cell.busy = false; }
|
|
271
|
+
return cell.res;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
localCell(name, env) {
|
|
275
|
+
for (let s = env.sc; s; s = s.up) if (s.names && s.names.has(name)) return s.names.get(name);
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What the file itself binds outside any expression this evaluator parses: `let` names and function parameters. */
|
|
280
|
+
fileInfo(file) {
|
|
281
|
+
if (this.fileInfoCache.has(file)) return this.fileInfoCache.get(file);
|
|
282
|
+
const ir = this.irs.get(file);
|
|
283
|
+
const info = { lets: new Map(), params: new Set(), complete: !!ir && !(ir.truncated || []).length };
|
|
284
|
+
if (ir) {
|
|
285
|
+
for (const b of ir.bindings) {
|
|
286
|
+
if (b.scope !== 'let') continue;
|
|
287
|
+
if (typeof b.path[0] !== 'string') { info.complete = false; continue; }
|
|
288
|
+
if (!info.lets.has(b.path[0])) info.lets.set(b.path[0], []);
|
|
289
|
+
info.lets.get(b.path[0]).push(b);
|
|
290
|
+
}
|
|
291
|
+
for (const f of ir.functions || []) { for (const p of f.params || []) info.params.add(p); if (f.atName) info.params.add(f.atName); }
|
|
292
|
+
}
|
|
293
|
+
this.fileInfoCache.set(file, info);
|
|
294
|
+
return info;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The environment an alias's expression is evaluated in: its own position, outside any local scope. */
|
|
298
|
+
aliasEnv(env, name) {
|
|
299
|
+
const h = this.fileInfo(env.file).lets.get(name)[0];
|
|
300
|
+
return { file: env.file, namespace: env.namespace, scope: env.scope, sc: null, withs: h.withs || [], pos: h.valueSpan, inRec: env.inRec };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
lookupIdent(name, env) {
|
|
304
|
+
const cell = this.localCell(name, env);
|
|
305
|
+
if (cell) return this.cellValue(cell);
|
|
306
|
+
if (this.target.args && hasOwn(this.target.args, name)) return known(this.target.args[name]);
|
|
307
|
+
const info = this.fileInfo(env.file);
|
|
308
|
+
if (info.lets.has(name)) {
|
|
309
|
+
const hits = info.lets.get(name);
|
|
310
|
+
const h = hits[0];
|
|
311
|
+
// The single file-level binding is used only when it lexically ENCLOSES the text being evaluated (its let spans it) and nothing
|
|
312
|
+
// else could bind the name (no other let, no function parameter, not inside a rec set).
|
|
313
|
+
if (hits.length !== 1 || h.origin !== 'let' || h.path.length !== 1 || info.params.has(name) || !h.valueSpan) return UNK;
|
|
314
|
+
if (env.inRec || !h.letSpan || !env.pos || !within(h.letSpan, env.pos)) return UNK;
|
|
315
|
+
const key = `${env.file}:${name}`;
|
|
316
|
+
if (this.aliasBusy.has(key)) return UNK;
|
|
317
|
+
const ast = this.parseAt(env.file, h.valueSpan);
|
|
318
|
+
if (!ast) return UNK;
|
|
319
|
+
this.aliasBusy.add(key);
|
|
320
|
+
try { return this.evalExpr(ast, { file: env.file, namespace: env.namespace, scope: env.scope, sc: null, withs: h.withs || [], pos: h.valueSpan, inRec: env.inRec }); } finally { this.aliasBusy.delete(key); }
|
|
321
|
+
}
|
|
322
|
+
if (name === 'true') return known(true);
|
|
323
|
+
if (name === 'false') return known(false);
|
|
324
|
+
if (name === 'null') return known(null);
|
|
325
|
+
// a name the module function or an enclosing function declares: its value is not supplied, so it is unknown
|
|
326
|
+
if (info.params.has(name) || !info.complete) return name === 'lib' && info.params.has('lib') ? known(new Ns('lib')) : UNK;
|
|
327
|
+
if (name === 'builtins' || isGlobalName(name)) return known(globalValue(name));
|
|
328
|
+
for (let s = env.sc; s; s = s.up) {
|
|
329
|
+
if (s.withv === undefined) continue;
|
|
330
|
+
if (s.withv === UNK_WITH) return UNK;
|
|
331
|
+
if (s.withv instanceof Ns) { const v = nsLookup(s.withv.kind, name); return v ? known(v) : UNK; }
|
|
332
|
+
if (hasOwn(s.withv, name)) return known(s.withv[name]);
|
|
333
|
+
}
|
|
334
|
+
const outer = env.withs || [];
|
|
335
|
+
for (let i = outer.length - 1; i >= 0; i--) {
|
|
336
|
+
const w = String(outer[i]).trim();
|
|
337
|
+
if (w === 'lib' || w === 'builtins') { const v = nsLookup(w, name); return v ? known(v) : UNK; }
|
|
338
|
+
return UNK;
|
|
339
|
+
}
|
|
340
|
+
return UNK;
|
|
341
|
+
}
|
|
342
|
+
|
|
248
343
|
// ── expression evaluation ──
|
|
249
344
|
|
|
250
345
|
tick() {
|
|
251
346
|
if (++this.steps > this.b.maxEvaluations) throw this.cap('maxEvaluations', this.b.maxEvaluations, 'static evaluation step budget exhausted');
|
|
252
347
|
}
|
|
253
348
|
|
|
349
|
+
size(n) {
|
|
350
|
+
if (n > this.b.maxValueSize) throw this.cap('maxValueSize', this.b.maxValueSize, `an evaluated list or string-list grew past ${this.b.maxValueSize} elements`);
|
|
351
|
+
}
|
|
352
|
+
|
|
254
353
|
parseAt(file, span) {
|
|
255
354
|
if (!span) return null;
|
|
256
355
|
const k = `${file}:${span.startOffset}:${span.endOffset}`;
|
|
@@ -263,12 +362,15 @@ class Resolver {
|
|
|
263
362
|
return ast;
|
|
264
363
|
}
|
|
265
364
|
|
|
266
|
-
|
|
267
|
-
|
|
365
|
+
/** The single `let` binding of `name` in `file`, but only if it lexically encloses the text at `env.pos` and nothing else could bind the name. */
|
|
366
|
+
alias(env, name, guard) {
|
|
367
|
+
const ir = this.irs.get(env.file);
|
|
268
368
|
const hits = ir ? ir.bindings.filter((b) => b.origin === 'let' && b.path.length === 1 && b.path[0] === name) : [];
|
|
269
|
-
|
|
270
|
-
guard.
|
|
271
|
-
|
|
369
|
+
const h = hits[0];
|
|
370
|
+
if (hits.length !== 1 || guard.has(`${env.file}:${name}`)) return null;
|
|
371
|
+
if (env.inRec || !h.letSpan || !env.pos || !within(h.letSpan, env.pos) || this.fileInfo(env.file).params.has(name)) return null;
|
|
372
|
+
guard.add(`${env.file}:${name}`);
|
|
373
|
+
return this.parseAt(env.file, h.valueSpan);
|
|
272
374
|
}
|
|
273
375
|
|
|
274
376
|
/** `config.a.b`, or `cfg.b` where `cfg = config.a`, as ['a','b']; otherwise null. */
|
|
@@ -281,29 +383,41 @@ class Resolver {
|
|
|
281
383
|
base = unparen(n.base); segs = n.attrpath.map((s) => s.name);
|
|
282
384
|
} else base = n;
|
|
283
385
|
if (!base || base.type !== 'ident') return null;
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
386
|
+
// a name bound by an enclosing `let` or lambda inside the expression: follow it only if it is itself a config path
|
|
387
|
+
const cell = this.localCell(base.name, env);
|
|
388
|
+
if (cell) {
|
|
389
|
+
if (!cell.expr || guard.has(cell)) return null;
|
|
390
|
+
guard.add(cell);
|
|
391
|
+
const head = this.configPathOf(cell.expr, cell.env, guard);
|
|
392
|
+
return head ? head.concat(segs) : null;
|
|
393
|
+
}
|
|
394
|
+
// a module-level `let config = ...` (or `pkgs`) shadows the module argument: that is not an option read
|
|
395
|
+
if (base.name === 'config' && !this.fileInfo(env.file).lets.has('config')) return segs.length ? segs : null;
|
|
396
|
+
const al = this.alias(env, base.name, guard);
|
|
397
|
+
const head = al ? this.configPathOf(al, this.aliasEnv(env, base.name), guard) : null;
|
|
287
398
|
return head ? head.concat(segs) : null;
|
|
288
399
|
}
|
|
289
400
|
|
|
290
401
|
evalExpr(node, env) {
|
|
291
402
|
this.tick();
|
|
403
|
+
if (++this.exprDepth > this.b.maxExprDepth) { this.exprDepth--; throw this.cap('maxExprDepth', this.b.maxExprDepth, `expression nesting deeper than ${this.b.maxExprDepth}`); }
|
|
404
|
+
try { return this.evalNode(node, env); } finally { this.exprDepth--; }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
evalNode(node, env) {
|
|
292
408
|
const n = unparen(node);
|
|
293
409
|
if (!n) return UNK;
|
|
294
410
|
switch (n.type) {
|
|
295
|
-
case 'ident':
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (this.target.args && Object.prototype.hasOwnProperty.call(this.target.args, n.name)) return known(this.target.args[n.name]);
|
|
300
|
-
return UNK;
|
|
301
|
-
case 'int': case 'float': return known(n.value);
|
|
302
|
-
case 'string': return n.interpolated ? UNK : known(n.literal);
|
|
411
|
+
case 'ident': return this.lookupIdent(n.name, env);
|
|
412
|
+
case 'int': return Number.isSafeInteger(n.value) ? known(n.value) : UNK;
|
|
413
|
+
case 'float': return known(n.value);
|
|
414
|
+
case 'string': return this.evalString(n, env);
|
|
303
415
|
case 'unop': {
|
|
304
|
-
if (n.op !== '!') return UNK;
|
|
305
416
|
const v = this.evalExpr(n.operand, env);
|
|
306
|
-
|
|
417
|
+
if (!v.known) return UNK;
|
|
418
|
+
if (n.op === '!') return typeof v.value === 'boolean' ? known(!v.value) : UNK;
|
|
419
|
+
if (n.op === '-') return Number.isSafeInteger(v.value) ? known(-v.value) : UNK;
|
|
420
|
+
return UNK;
|
|
307
421
|
}
|
|
308
422
|
case 'binop': return this.evalBinop(n, env);
|
|
309
423
|
// A list or attribute set is known only when EVERY element is statically known (so `[ { from = 1; to = 65535; } ]` is, and a list
|
|
@@ -314,30 +428,169 @@ class Resolver {
|
|
|
314
428
|
for (const it of n.items) { const v = this.evalExpr(it, env); if (!v.known) return UNK; out.push(v.value); }
|
|
315
429
|
return known(out);
|
|
316
430
|
}
|
|
317
|
-
case 'attrset':
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
return known(out);
|
|
431
|
+
case 'attrset': return this.evalAttrset(n, env);
|
|
432
|
+
case 'select': return this.evalSelect(n, env);
|
|
433
|
+
case 'if': {
|
|
434
|
+
const c = this.evalExpr(n.cond, env);
|
|
435
|
+
if (!c.known || typeof c.value !== 'boolean') return UNK;
|
|
436
|
+
return this.evalExpr(c.value ? n.then : n.else, env);
|
|
437
|
+
}
|
|
438
|
+
case 'assert': {
|
|
439
|
+
const c = this.evalExpr(n.cond, env);
|
|
440
|
+
return c.known && c.value === true ? this.evalExpr(n.body, env) : UNK;
|
|
329
441
|
}
|
|
330
|
-
case '
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
442
|
+
case 'let': return this.evalLet(n, env);
|
|
443
|
+
case 'with': {
|
|
444
|
+
const e = this.evalExpr(n.env, env);
|
|
445
|
+
const w = e.known && (e.value instanceof Ns ? e.value : isAttrs(e.value) ? e.value : UNK_WITH);
|
|
446
|
+
return this.evalExpr(n.body, { ...env, sc: this.scopeOf(env, null, w || UNK_WITH) });
|
|
447
|
+
}
|
|
448
|
+
case 'lambda': return known(new Closure(n.param, n.body, env));
|
|
449
|
+
case 'app': {
|
|
450
|
+
const f = this.evalExpr(n.fn, env);
|
|
451
|
+
if (!f.known) return UNK;
|
|
452
|
+
const a = this.evalExpr(n.arg, env);
|
|
453
|
+
return a.known ? this.applyValue(f.value, a.value) : UNK;
|
|
336
454
|
}
|
|
337
455
|
default: return UNK;
|
|
338
456
|
}
|
|
339
457
|
}
|
|
340
458
|
|
|
459
|
+
evalString(n, env) {
|
|
460
|
+
if (!n.interpolated) return known(n.literal);
|
|
461
|
+
let out = '';
|
|
462
|
+
for (const p of n.parts) {
|
|
463
|
+
if (p.kind === 'text') { out += p.value; continue; }
|
|
464
|
+
const v = this.evalExpr(p.expr, env);
|
|
465
|
+
if (!v.known || typeof v.value !== 'string') return UNK;
|
|
466
|
+
out += v.value;
|
|
467
|
+
if (out.length > 65_536) return UNK;
|
|
468
|
+
}
|
|
469
|
+
return known(out);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
evalAttrset(n, env) {
|
|
473
|
+
if (n.rec || n.errors || n.bindings.length > 256) return UNK;
|
|
474
|
+
const out = {};
|
|
475
|
+
const implicit = new WeakSet();
|
|
476
|
+
for (const b of n.bindings) {
|
|
477
|
+
let path; let valueRes;
|
|
478
|
+
if (b.kind === 'inherit') {
|
|
479
|
+
// `inherit a b;` reads the names from the enclosing scope. `inherit (e) a;` is not modelled.
|
|
480
|
+
if (b.from) return UNK;
|
|
481
|
+
for (const nm of b.names) {
|
|
482
|
+
if (nm.kind !== 'static' || typeof nm.name !== 'string') return UNK;
|
|
483
|
+
const v = this.lookupIdent(nm.name, env);
|
|
484
|
+
if (!v.known || hasOwn(out, nm.name)) return UNK;
|
|
485
|
+
setAttr(out, nm.name, v.value);
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (b.kind !== 'attr' || !b.path.length || !b.path.every((seg) => seg.kind === 'static' && typeof seg.name === 'string')) return UNK;
|
|
490
|
+
path = b.path;
|
|
491
|
+
valueRes = this.evalExpr(b.value, env);
|
|
492
|
+
if (!valueRes.known) return UNK;
|
|
493
|
+
let at = out;
|
|
494
|
+
for (const seg of path.slice(0, -1)) {
|
|
495
|
+
if (!hasOwn(at, seg.name)) { const sub = {}; implicit.add(sub); setAttr(at, seg.name, sub); }
|
|
496
|
+
else if (!implicit.has(at[seg.name])) return UNK;
|
|
497
|
+
at = at[seg.name];
|
|
498
|
+
}
|
|
499
|
+
const last = path[path.length - 1].name;
|
|
500
|
+
if (hasOwn(at, last)) return UNK;
|
|
501
|
+
setAttr(at, last, valueRes.value);
|
|
502
|
+
}
|
|
503
|
+
return known(out);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
evalLet(n, env) {
|
|
507
|
+
const names = new Map();
|
|
508
|
+
const bound = new Set();
|
|
509
|
+
for (const b of n.bindings) {
|
|
510
|
+
if (b.kind === 'attr') {
|
|
511
|
+
if (b.path.length !== 1 || b.path[0].kind !== 'static' || typeof b.path[0].name !== 'string' || bound.has(b.path[0].name)) return UNK;
|
|
512
|
+
bound.add(b.path[0].name);
|
|
513
|
+
} else if (b.kind === 'inherit') {
|
|
514
|
+
for (const nm of b.names) { if (nm.kind !== 'static' || typeof nm.name !== 'string' || bound.has(nm.name)) return UNK; bound.add(nm.name); }
|
|
515
|
+
} else return UNK;
|
|
516
|
+
}
|
|
517
|
+
const inner = { ...env, sc: this.scopeOf(env, names) };
|
|
518
|
+
for (const b of n.bindings) {
|
|
519
|
+
if (b.kind === 'attr') { names.set(b.path[0].name, { expr: b.value, env: inner }); continue; }
|
|
520
|
+
// `inherit x;` / `inherit (lib) x;`: evaluated in the scope AROUND the let. Only `lib` / `builtins` are modelled as a source,
|
|
521
|
+
// and only when this let does not itself bind that name.
|
|
522
|
+
let from = null;
|
|
523
|
+
if (b.from) {
|
|
524
|
+
const f = unparen(b.from);
|
|
525
|
+
if (!f || f.type !== 'ident' || bound.has(f.name)) return UNK;
|
|
526
|
+
from = f;
|
|
527
|
+
}
|
|
528
|
+
for (const nm of b.names) {
|
|
529
|
+
// a plain `inherit x;` of a name this let also binds would be ambiguous, and `bound` forbids it above (duplicate)
|
|
530
|
+
const expr = from
|
|
531
|
+
? { type: 'select', base: from, attrpath: [{ kind: 'static', name: nm.name }], default: null }
|
|
532
|
+
: { type: 'ident', name: nm.name };
|
|
533
|
+
names.set(nm.name, { expr, env });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return this.evalExpr(n.body, inner);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
evalSelect(n, env) {
|
|
540
|
+
const flags = platformFlags(this.target.system);
|
|
541
|
+
const b = unparen(n.base);
|
|
542
|
+
if (flags && b && b.type === 'ident' && b.name === 'pkgs' && !this.localCell('pkgs', env) && !this.fileInfo(env.file).lets.has('pkgs') && n.attrpath.length === 2 && n.attrpath[0].name === 'stdenv' && n.attrpath[1].name in flags) return known(flags[n.attrpath[1].name]);
|
|
543
|
+
const p = this.configPathOf(n, env);
|
|
544
|
+
if (p) return this.optionValue(env, p);
|
|
545
|
+
if (n.attrpath.some((s) => s.kind !== 'static' || typeof s.name !== 'string')) return UNK;
|
|
546
|
+
let cur = this.evalExpr(n.base, env);
|
|
547
|
+
for (const s of n.attrpath) {
|
|
548
|
+
if (!cur.known) return UNK;
|
|
549
|
+
const v = cur.value;
|
|
550
|
+
if (v instanceof Ns) { const m = nsLookup(v.kind, s.name); cur = m ? known(m) : UNK; }
|
|
551
|
+
else if (isAttrs(v) && hasOwn(v, s.name)) cur = known(v[s.name]);
|
|
552
|
+
else if (isAttrs(v) && n.default) return this.evalExpr(n.default, env);
|
|
553
|
+
else return UNK;
|
|
554
|
+
}
|
|
555
|
+
return cur;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
applyValue(f, a) {
|
|
559
|
+
if (f instanceof Builtin) {
|
|
560
|
+
const args = [...f.args, a];
|
|
561
|
+
if (args.length < f.def.arity) return known(new Builtin(f.name, f.def, args));
|
|
562
|
+
try { return known(f.def.call(args, this.callCtx())); } catch (e) { if (e === NOT_STATIC) return UNK; throw e; }
|
|
563
|
+
}
|
|
564
|
+
if (f instanceof Closure) {
|
|
565
|
+
const names = new Map();
|
|
566
|
+
const p = f.param;
|
|
567
|
+
const env = { ...f.env, sc: this.scopeOf(f.env, names) };
|
|
568
|
+
if (p.kind === 'ident') names.set(p.name, { res: known(a) });
|
|
569
|
+
else {
|
|
570
|
+
if (!isAttrs(a)) return UNK;
|
|
571
|
+
if (p.atName) names.set(p.atName, { res: known(a) });
|
|
572
|
+
for (const fm of p.formals) {
|
|
573
|
+
if (hasOwn(a, fm.name)) names.set(fm.name, { res: known(a[fm.name]) });
|
|
574
|
+
else if (fm.default) names.set(fm.name, { expr: fm.default, env });
|
|
575
|
+
else return UNK;
|
|
576
|
+
}
|
|
577
|
+
if (!p.ellipsis && Object.keys(a).some((k) => !p.formals.some((fm) => fm.name === k))) return UNK;
|
|
578
|
+
}
|
|
579
|
+
return this.evalExpr(f.body, env);
|
|
580
|
+
}
|
|
581
|
+
return UNK;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
callCtx() {
|
|
585
|
+
if (!this.ctx) {
|
|
586
|
+
this.ctx = {
|
|
587
|
+
apply: (f, x) => { const r = this.applyValue(f, x); if (!r.known) throw NOT_STATIC; return r.value; },
|
|
588
|
+
size: (n) => this.size(n),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
return this.ctx;
|
|
592
|
+
}
|
|
593
|
+
|
|
341
594
|
evalBinop(n, env) {
|
|
342
595
|
const bool = (r) => (r.known && typeof r.value === 'boolean' ? r.value : null);
|
|
343
596
|
if (['&&', '||', '->'].includes(n.op)) {
|
|
@@ -347,16 +600,48 @@ class Resolver {
|
|
|
347
600
|
if (n.op === '||') return l === true || r === true ? known(true) : l === false && r === false ? known(false) : UNK;
|
|
348
601
|
return l === false || r === true ? known(true) : l === true && r === false ? known(false) : UNK;
|
|
349
602
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
603
|
+
const l = this.evalExpr(n.left, env);
|
|
604
|
+
const r = this.evalExpr(n.right, env);
|
|
605
|
+
if (!l.known || !r.known) return UNK;
|
|
606
|
+
const a = l.value; const b = r.value;
|
|
607
|
+
switch (n.op) {
|
|
608
|
+
case '==': case '!=': {
|
|
609
|
+
const eq = deepEq(a, b);
|
|
610
|
+
return eq === null ? UNK : known(n.op === '==' ? eq : !eq);
|
|
611
|
+
}
|
|
612
|
+
case '++': {
|
|
613
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return UNK;
|
|
614
|
+
this.size(a.length + b.length);
|
|
615
|
+
return known(a.concat(b));
|
|
616
|
+
}
|
|
617
|
+
case '//': {
|
|
618
|
+
if (!isAttrs(a) || !isAttrs(b)) return UNK;
|
|
619
|
+
const out = {};
|
|
620
|
+
for (const k of Object.keys(a)) setAttr(out, k, a[k]);
|
|
621
|
+
for (const k of Object.keys(b)) setAttr(out, k, b[k]);
|
|
622
|
+
return known(out);
|
|
623
|
+
}
|
|
624
|
+
case '+': {
|
|
625
|
+
if (typeof a === 'string' && typeof b === 'string') return a.length + b.length <= 65_536 ? known(a + b) : UNK;
|
|
626
|
+
if (Number.isSafeInteger(a) && Number.isSafeInteger(b) && Number.isSafeInteger(a + b)) return known(a + b);
|
|
627
|
+
return UNK;
|
|
628
|
+
}
|
|
629
|
+
case '-': return Number.isSafeInteger(a) && Number.isSafeInteger(b) && Number.isSafeInteger(a - b) ? known(a - b) : UNK;
|
|
630
|
+
case '*': return Number.isSafeInteger(a) && Number.isSafeInteger(b) && Number.isSafeInteger(a * b) ? known(a * b) : UNK;
|
|
631
|
+
case '<': case '>': case '<=': case '>=': {
|
|
632
|
+
if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b)) return UNK;
|
|
633
|
+
return known(n.op === '<' ? a < b : n.op === '>' ? a > b : n.op === '<=' ? a <= b : a >= b);
|
|
634
|
+
}
|
|
635
|
+
default: return UNK;
|
|
356
636
|
}
|
|
357
|
-
return UNK;
|
|
358
637
|
}
|
|
359
638
|
|
|
639
|
+
/** The final value of a definition: anything that is not plain data (a function, a partial application, a namespace) is unknown. */
|
|
640
|
+
sealed(r) { return r.known && isPlain(r.value) ? r : UNK; }
|
|
641
|
+
|
|
642
|
+
/** Evaluation environment of a definition. `pos` is the absolute span of the text being evaluated (see lookupIdent's alias check). */
|
|
643
|
+
envOf(def, pos = def.valueSpan, withs = def.withs) { return { file: def.file, namespace: def.namespace, scope: def.scope, sc: null, withs: withs || [], pos, inRec: def.inRec }; }
|
|
644
|
+
|
|
360
645
|
optionValue(env, segs) {
|
|
361
646
|
const res = this.resolve(env.namespace, env.scope, segs.join('.'));
|
|
362
647
|
return (res.status === 'set' || res.status === 'default') && res.valueKnown ? known(res.value) : UNK;
|
|
@@ -365,26 +650,36 @@ class Resolver {
|
|
|
365
650
|
condOutcome(def, c) {
|
|
366
651
|
const ast = this.parseAt(def.file, c.span);
|
|
367
652
|
if (!ast) return 'unknown';
|
|
368
|
-
const r = this.evalExpr(ast,
|
|
653
|
+
const r = this.evalExpr(ast, this.envOf(def, c.span));
|
|
369
654
|
if (!r.known || typeof r.value !== 'boolean') return 'unknown';
|
|
370
655
|
const truth = c.branch === 'else' ? !r.value : r.value;
|
|
371
656
|
return truth ? 'true' : 'false';
|
|
372
657
|
}
|
|
373
658
|
|
|
374
659
|
defValue(def) {
|
|
660
|
+
// The value sits under a let / with / assert: evaluate the WHOLE wrapper so its bindings, shadowing and asserts apply.
|
|
661
|
+
if (def.evalSpan) {
|
|
662
|
+
const whole = this.parseAt(def.file, def.evalSpan);
|
|
663
|
+
return whole ? this.sealed(this.evalExpr(whole, this.envOf(def, def.evalSpan, def.evalWiths))) : UNK;
|
|
664
|
+
}
|
|
375
665
|
const s = def.summary;
|
|
376
666
|
switch (s.type) {
|
|
377
|
-
case 'bool': case '
|
|
378
|
-
case '
|
|
667
|
+
case 'bool': case 'null': return known(s.value);
|
|
668
|
+
case 'number': return Number.isInteger(s.value) && !Number.isSafeInteger(s.value) ? UNK : known(s.value);
|
|
669
|
+
case 'string': {
|
|
670
|
+
if (!s.interpolated) return known(s.literal);
|
|
671
|
+
const ast = this.parseAt(def.file, def.valueSpan);
|
|
672
|
+
return ast ? this.sealed(this.evalExpr(ast, this.envOf(def))) : UNK;
|
|
673
|
+
}
|
|
379
674
|
case 'list': {
|
|
380
675
|
if (s.items.length < s.length) return UNK;
|
|
381
676
|
const out = [];
|
|
382
677
|
for (const it of s.items) {
|
|
383
|
-
const v = it.type === 'string' && !it.interpolated ? known(it.literal) : it.type === 'number' || it.type === 'bool' ? known(it.value) : UNK;
|
|
678
|
+
const v = it.type === 'string' && !it.interpolated ? known(it.literal) : (it.type === 'number' && !(Number.isInteger(it.value) && !Number.isSafeInteger(it.value))) || it.type === 'bool' ? known(it.value) : UNK;
|
|
384
679
|
if (!v.known) {
|
|
385
680
|
// Not a list of scalars (for example a list of attribute sets): evaluate the whole expression from its source instead.
|
|
386
681
|
const ast = this.parseAt(def.file, def.valueSpan);
|
|
387
|
-
return ast ? this.evalExpr(ast,
|
|
682
|
+
return ast ? this.sealed(this.evalExpr(ast, this.envOf(def))) : UNK;
|
|
388
683
|
}
|
|
389
684
|
out.push(v.value);
|
|
390
685
|
}
|
|
@@ -392,7 +687,7 @@ class Resolver {
|
|
|
392
687
|
}
|
|
393
688
|
case 'expression': case 'reference': {
|
|
394
689
|
const ast = this.parseAt(def.file, def.valueSpan);
|
|
395
|
-
return ast ? this.evalExpr(ast,
|
|
690
|
+
return ast ? this.sealed(this.evalExpr(ast, this.envOf(def))) : UNK;
|
|
396
691
|
}
|
|
397
692
|
default: return UNK;
|
|
398
693
|
}
|
|
@@ -16,7 +16,8 @@ import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
|
|
|
16
16
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
17
17
|
import { buildResolvedGraph } from './haskell-resolved-graph.js';
|
|
18
18
|
import { importNixClosure } from './nix-closure.js';
|
|
19
|
-
import { NixAdvisoryData, matchNixVulnerabilities, overlayEvidence, HASKELL_ENV_HINTS } from './nix-sca.js';
|
|
19
|
+
import { NixAdvisoryData, matchNixVulnerabilities, overlayEvidence, closureIdentities, HASKELL_ENV_HINTS } from './nix-sca.js';
|
|
20
|
+
import { loadLiveSnapshot, SNAPSHOT_SCHEMA as NIX_LIVE_SCHEMA, refreshNixAdvisories, liveFeedEnabled as nixLiveEnabled, getLastRefresh as nixLastRefresh, FEED_ENV as NIX_FEED_ENV } from './nix-advisory-feed.js';
|
|
20
21
|
import { configuredAdvisoryDb, collectUsage, manifestFiles } from './haskell-supply.js';
|
|
21
22
|
import { runIsolatedEval, mergeEvaluationHealth, _which } from './nix-eval-isolation.js';
|
|
22
23
|
|
|
@@ -140,10 +141,58 @@ export function configuredNixAdvisories(root, env = process.env, hackageDb = nul
|
|
|
140
141
|
const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
|
|
141
142
|
if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('nix-advisories.json') : ''}` };
|
|
142
143
|
let snap; try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
|
|
144
|
+
if (snap && snap.schema === NIX_LIVE_SCHEMA) {
|
|
145
|
+
// A snapshot the live feed wrote: its records must match their hashes, and it carries which CPE identities it actually read.
|
|
146
|
+
const live = loadLiveSnapshot(snap);
|
|
147
|
+
if (!live.ok) return { data: null, reason: `the live Nix advisory snapshot was refused: ${live.reason}` };
|
|
148
|
+
return { data: new NixAdvisoryData({ records: live.records, hackage: hackageDb, source: sel.source === 'env' ? 'pinned-snapshot' : 'nvd-live-feed', generatedAt: live.generatedAt, covered: live.covered }), reason: null };
|
|
149
|
+
}
|
|
143
150
|
const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
|
|
144
151
|
return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
|
|
145
152
|
}
|
|
146
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Package metadata for the closure (meta.identifiers, knownVulnerabilities, license), from a file the OPERATOR names in
|
|
156
|
+
* AGENTIC_SECURITY_NIX_META: either `{pname: meta}` or the `nix-env -qa --meta --json` shape (`{attr: {pname, meta}}`). It is never
|
|
157
|
+
* read from the scanned project, because metadata decides which upstream identity a component is matched under.
|
|
158
|
+
*/
|
|
159
|
+
export function configuredNixMeta(env = process.env) {
|
|
160
|
+
const p = env.AGENTIC_SECURITY_NIX_META;
|
|
161
|
+
if (!p) return { meta: {}, reason: null };
|
|
162
|
+
let raw;
|
|
163
|
+
try { const text = readFileSync(p, 'utf8'); if (text.length > (32 << 20)) return { meta: {}, reason: 'the Nix metadata file is larger than 32 MB and was not read' }; raw = JSON.parse(text); } catch (e) { return { meta: {}, reason: `the Nix metadata file is unreadable: ${e.code || e.message}` }; }
|
|
164
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { meta: {}, reason: 'the Nix metadata file is not a JSON object' };
|
|
165
|
+
const meta = {};
|
|
166
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
167
|
+
if (!v || typeof v !== 'object') continue;
|
|
168
|
+
const m = v.meta && typeof v.meta === 'object' ? v.meta : v;
|
|
169
|
+
const name = typeof v.pname === 'string' && v.pname ? v.pname : k.split('.').pop();
|
|
170
|
+
if (/^[\w.+-]{1,100}$/.test(name) && !Object.prototype.hasOwnProperty.call(meta, name)) meta[name] = m;
|
|
171
|
+
}
|
|
172
|
+
return { meta, reason: null };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The async step before a scan's synchronous passes: when the live Nix feed is enabled, make sure the operator snapshot covers every
|
|
177
|
+
* CPE identity the imported closure declares. Does nothing, and costs nothing, unless the feed is enabled. Never throws.
|
|
178
|
+
*/
|
|
179
|
+
export async function prefetchNixAdvisoryFeed(files, { env = process.env, ...opts } = {}) {
|
|
180
|
+
if (!nixLiveEnabled(env)) return null;
|
|
181
|
+
try {
|
|
182
|
+
const c = nixClosureOf(files, opts.now ? { now: opts.now } : {});
|
|
183
|
+
if (!c || (c.refused && c.refused.length)) return null;
|
|
184
|
+
const { cpes } = closureIdentities({ closure: c.closure, drvEnv: c.drvEnv, meta: configuredNixMeta(env).meta, overlays: overlayEvidence(files) });
|
|
185
|
+
if (!cpes.length) return null;
|
|
186
|
+
return await refreshNixAdvisories(cpes, { env, ...opts });
|
|
187
|
+
} catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function nixLiveFeedNote(env) {
|
|
191
|
+
const r = nixLastRefresh();
|
|
192
|
+
if (r && r.status !== 'disabled') return ` Live feed: ${r.status}, ${r.detail}.`;
|
|
193
|
+
return env[NIX_FEED_ENV] === '1' ? '' : ` To fetch advisories for the closure's upstream software from the NVD instead, set ${NIX_FEED_ENV}=1 (network, opt-in).`;
|
|
194
|
+
}
|
|
195
|
+
|
|
147
196
|
/** Closure statuses and findings, with every disclosure the import and the feed produced. */
|
|
148
197
|
export function analyzeNixClosure(files, opts = {}) {
|
|
149
198
|
return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
|
|
@@ -153,16 +202,21 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
|
|
|
153
202
|
if (!c) return null;
|
|
154
203
|
const hackage = configuredAdvisoryDb(scanRoot, env).db;
|
|
155
204
|
const adv = configuredNixAdvisories(scanRoot, env, hackage);
|
|
205
|
+
if (!adv.data) adv.reason = `${adv.reason}${nixLiveFeedNote(env)}`;
|
|
206
|
+
const nm = configuredNixMeta(env);
|
|
156
207
|
let usage = null; try { usage = collectUsage(files); } catch { usage = null; }
|
|
157
208
|
let matched = null;
|
|
158
|
-
if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
209
|
+
if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, meta: nm.meta, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
159
210
|
const gaps = [];
|
|
160
211
|
if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
|
|
161
212
|
for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
|
|
162
213
|
for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
|
|
163
214
|
for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
|
|
164
215
|
if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
|
|
165
|
-
|
|
216
|
+
if (nm.reason) gaps.push({ kind: 'closure-meta-unreadable', detail: nm.reason });
|
|
217
|
+
const notCovered = ((matched && matched.statuses) || []).filter((x) => x.feedCoverage === 'incomplete');
|
|
218
|
+
if (adv.data && notCovered.length) gaps.push({ kind: 'closure-advisory-feed-incomplete', detail: `${notCovered.length} component(s) were not covered by the live advisory feed (${[...new Set(notCovered.map((x) => x.name))].slice(0, 5).join(', ')}${notCovered.length > 5 ? ', ...' : ''}): their status is unknown, and the absence of findings for them is not a clean result.` });
|
|
219
|
+
if (!adv.data) { /* reported above */ } else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
|
|
166
220
|
const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
|
|
167
221
|
return { status: c.closure.status, claims: c.closure.claims, sources: c.sources, summary: matched ? matched.summary : null, feed: matched ? matched.feed : null, statuses: matched ? matched.statuses : [], findings, gaps, licenses: matched ? matched.licenses : null, closure: c.closure };
|
|
168
222
|
}
|
package/src/lsp/server.js
CHANGED
|
@@ -268,11 +268,14 @@ async function codeActionsFor(params) {
|
|
|
268
268
|
let prev;
|
|
269
269
|
try { prev = await languageFixPreview(f, files); } catch (e) { prev = { ok: false, reason: String((e && e.message) || e) }; }
|
|
270
270
|
if (prev && prev.ok && prev.file) {
|
|
271
|
-
|
|
271
|
+
// A fix that spans several files (the winning definitions of one option) is ONE workspace edit over all of them: offering
|
|
272
|
+
// only the first file would leave the configuration in a state the verification never saw.
|
|
273
|
+
const changes = {};
|
|
274
|
+
for (const e of (prev.edits || [{ file: prev.file, before: prev.before, after: prev.after }])) changes[pathToUri(path.join(_rootDir, e.file))] = [minimalEdit(e.before, e.after)];
|
|
272
275
|
actions.push({
|
|
273
276
|
title: `Fix${prev.label ? ` (${prev.label})` : ''}: ${f.vuln || f.rule || 'finding'}`,
|
|
274
277
|
kind: 'quickfix', diagnostics: [d], isPreferred: false,
|
|
275
|
-
edit: { changes
|
|
278
|
+
edit: { changes },
|
|
276
279
|
data: { stableId: f.stableId || null, verified: { syntax: true, rescan: true } },
|
|
277
280
|
});
|
|
278
281
|
} else {
|
package/src/mcp/tools.js
CHANGED
|
@@ -1079,7 +1079,9 @@ export const synthesize_fix = {
|
|
|
1079
1079
|
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
1080
1080
|
const prev = await lc.languageFixPreview(f, proj.files);
|
|
1081
1081
|
languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
|
|
1082
|
-
|
|
1082
|
+
// apply_fix writes ONE file; a fix that spans several files must go through the CLI lifecycle (atomic, grouped undo).
|
|
1083
|
+
if (prev.ok && prev.edits) languageFix.multiFile = prev.edits.map((e) => e.file);
|
|
1084
|
+
else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
|
|
1083
1085
|
}
|
|
1084
1086
|
} catch { /* best-effort: the preview is advisory */ }
|
|
1085
1087
|
}
|