@clear-capabilities/agentic-security-scanner 0.158.0 → 0.159.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 (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/3531.index.js +1 -1
  5. package/dist/4171.index.js +4 -4
  6. package/dist/{8551.index.js → 7033.index.js} +415 -22
  7. package/dist/9560.index.js +3 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +6 -4
  11. package/src/engine.js +6 -1
  12. package/src/language/CLAUDE.md +48 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +15 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +4 -3
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +10 -6
  25. package/src/language/haskell-supply.js +42 -8
  26. package/src/language/haskell-web.js +128 -23
  27. package/src/language/nix-advisory-feed.js +275 -0
  28. package/src/language/nix-fix.js +162 -24
  29. package/src/language/nix-ir.js +16 -12
  30. package/src/language/nix-sca.js +71 -12
  31. package/src/language/nixos-eval-forms.js +162 -0
  32. package/src/language/nixos-module-resolver.js +346 -51
  33. package/src/language/resolved-pass.js +57 -3
  34. package/src/lsp/server.js +5 -2
  35. package/src/mcp/tools.js +3 -1
  36. package/src/posture/fix-history.js +30 -3
  37. package/src/report/index.js +4 -1
  38. package/dist/1310.index.js +0 -3161
  39. package/dist/2376.index.js +0 -3151
  40. package/dist/3526.index.js +0 -1019
  41. package/dist/7310.index.js +0 -520
  42. package/dist/8520.index.js +0 -13
  43. 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
- alias(file, name, guard) {
267
- const ir = this.irs.get(file);
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
- if (hits.length !== 1 || guard.has(`${file}:${name}`)) return null;
270
- guard.add(`${file}:${name}`);
271
- return this.parseAt(file, hits[0].valueSpan);
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
- if (base.name === 'config') return segs.length ? segs : null;
285
- const al = this.alias(env.file, base.name, guard);
286
- const head = al ? this.configPathOf(al, env, guard) : null;
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
- if (n.name === 'true') return known(true);
297
- if (n.name === 'false') return known(false);
298
- if (n.name === 'null') return known(null);
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
- return v.known && typeof v.value === 'boolean' ? known(!v.value) : UNK;
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
- if (n.rec || n.errors || n.bindings.length > 256) return UNK;
319
- const out = {};
320
- for (const b of n.bindings) {
321
- if (b.kind !== 'attr' || !b.path.length || !b.path.every((seg) => seg.kind === 'static' && typeof seg.name === 'string')) return UNK;
322
- const v = this.evalExpr(b.value, env);
323
- if (!v.known) return UNK;
324
- let at = out;
325
- for (const seg of b.path.slice(0, -1)) { if (typeof at[seg.name] !== 'object' || at[seg.name] === null) at[seg.name] = {}; at = at[seg.name]; }
326
- at[b.path[b.path.length - 1].name] = v.value;
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 'select': {
331
- const flags = platformFlags(this.target.system);
332
- const b = unparen(n.base);
333
- if (flags && b && b.type === 'ident' && b.name === 'pkgs' && n.attrpath.length === 2 && n.attrpath[0].name === 'stdenv' && n.attrpath[1].name in flags) return known(flags[n.attrpath[1].name]);
334
- const p = this.configPathOf(n, env);
335
- return p ? this.optionValue(env, p) : UNK;
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
- if (n.op === '==' || n.op === '!=') {
351
- const l = this.evalExpr(n.left, env);
352
- const r = this.evalExpr(n.right, env);
353
- if (!l.known || !r.known) return UNK;
354
- const eq = sameValue(l.value, r.value);
355
- return known(n.op === '==' ? eq : !eq);
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, { file: def.file, namespace: def.namespace, scope: def.scope });
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 'number': case 'null': return known(s.value);
378
- case 'string': return s.interpolated ? UNK : known(s.literal);
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, { file: def.file, namespace: def.namespace, scope: def.scope }) : UNK;
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, { file: def.file, namespace: def.namespace, scope: def.scope }) : UNK;
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
- 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`})` });
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
- const target = pathToUri(path.join(_rootDir, prev.file));
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: { [target]: [minimalEdit(prev.before, prev.after)] } },
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
- if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
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
  }