@clear-capabilities/agentic-security-scanner 0.157.2 → 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 (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/4171.index.js +2 -2
  5. package/dist/{8551.index.js → 7033.index.js} +430 -22
  6. package/dist/9560.index.js +3 -1
  7. package/dist/agentic-security.mjs +15 -15
  8. package/dist/agentic-security.mjs.sha256 +1 -1
  9. package/package.json +7 -5
  10. package/src/dataflow/engine.js +23 -0
  11. package/src/engine.js +7 -1
  12. package/src/language/CLAUDE.md +53 -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 +194 -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 +12 -5
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +28 -10
  25. package/src/language/haskell-security-rules.js +1 -1
  26. package/src/language/haskell-supply.js +70 -8
  27. package/src/language/haskell-web.js +143 -10
  28. package/src/language/nix-advisory-feed.js +275 -0
  29. package/src/language/nix-fix.js +162 -24
  30. package/src/language/nix-ir.js +16 -12
  31. package/src/language/nix-sca.js +80 -15
  32. package/src/language/nixos-eval-forms.js +162 -0
  33. package/src/language/nixos-hardening.js +37 -2
  34. package/src/language/nixos-module-resolver.js +359 -39
  35. package/src/language/nixos-option-catalog.js +1 -0
  36. package/src/language/resolved-pass.js +65 -3
  37. package/src/lsp/server.js +5 -2
  38. package/src/mcp/tools.js +3 -1
  39. package/src/posture/fix-history.js +30 -3
  40. package/src/report/index.js +4 -1
  41. package/dist/1310.index.js +0 -3161
  42. package/dist/2376.index.js +0 -3151
  43. package/dist/3526.index.js +0 -1019
  44. package/dist/7310.index.js +0 -520
  45. package/dist/8520.index.js +0 -13
  46. 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,42 +383,214 @@ 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
- case 'select': {
310
- const flags = platformFlags(this.target.system);
311
- const b = unparen(n.base);
312
- 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]);
313
- const p = this.configPathOf(n, env);
314
- return p ? this.optionValue(env, p) : UNK;
423
+ // A list or attribute set is known only when EVERY element is statically known (so `[ { from = 1; to = 65535; } ]` is, and a list
424
+ // containing `config.x` or an interpolated string is not). Bounded; recursive sets and dynamic attribute names stay unknown.
425
+ case 'list': {
426
+ if (n.items.length > 256) return UNK;
427
+ const out = [];
428
+ for (const it of n.items) { const v = this.evalExpr(it, env); if (!v.known) return UNK; out.push(v.value); }
429
+ return known(out);
430
+ }
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;
441
+ }
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;
315
454
  }
316
455
  default: return UNK;
317
456
  }
318
457
  }
319
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
+
320
594
  evalBinop(n, env) {
321
595
  const bool = (r) => (r.known && typeof r.value === 'boolean' ? r.value : null);
322
596
  if (['&&', '||', '->'].includes(n.op)) {
@@ -326,16 +600,48 @@ class Resolver {
326
600
  if (n.op === '||') return l === true || r === true ? known(true) : l === false && r === false ? known(false) : UNK;
327
601
  return l === false || r === true ? known(true) : l === true && r === false ? known(false) : UNK;
328
602
  }
329
- if (n.op === '==' || n.op === '!=') {
330
- const l = this.evalExpr(n.left, env);
331
- const r = this.evalExpr(n.right, env);
332
- if (!l.known || !r.known) return UNK;
333
- const eq = sameValue(l.value, r.value);
334
- 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;
335
636
  }
336
- return UNK;
337
637
  }
338
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
+
339
645
  optionValue(env, segs) {
340
646
  const res = this.resolve(env.namespace, env.scope, segs.join('.'));
341
647
  return (res.status === 'set' || res.status === 'default') && res.valueKnown ? known(res.value) : UNK;
@@ -344,30 +650,44 @@ class Resolver {
344
650
  condOutcome(def, c) {
345
651
  const ast = this.parseAt(def.file, c.span);
346
652
  if (!ast) return 'unknown';
347
- 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));
348
654
  if (!r.known || typeof r.value !== 'boolean') return 'unknown';
349
655
  const truth = c.branch === 'else' ? !r.value : r.value;
350
656
  return truth ? 'true' : 'false';
351
657
  }
352
658
 
353
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
+ }
354
665
  const s = def.summary;
355
666
  switch (s.type) {
356
- case 'bool': case 'number': case 'null': return known(s.value);
357
- 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
+ }
358
674
  case 'list': {
359
675
  if (s.items.length < s.length) return UNK;
360
676
  const out = [];
361
677
  for (const it of s.items) {
362
- const v = it.type === 'string' && !it.interpolated ? known(it.literal) : it.type === 'number' || it.type === 'bool' ? known(it.value) : UNK;
363
- if (!v.known) return 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;
679
+ if (!v.known) {
680
+ // Not a list of scalars (for example a list of attribute sets): evaluate the whole expression from its source instead.
681
+ const ast = this.parseAt(def.file, def.valueSpan);
682
+ return ast ? this.sealed(this.evalExpr(ast, this.envOf(def))) : UNK;
683
+ }
364
684
  out.push(v.value);
365
685
  }
366
686
  return known(out);
367
687
  }
368
688
  case 'expression': case 'reference': {
369
689
  const ast = this.parseAt(def.file, def.valueSpan);
370
- 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;
371
691
  }
372
692
  default: return UNK;
373
693
  }
@@ -37,6 +37,7 @@ const NIXOS_OPTIONS = {
37
37
  'security.sudo.extraConfig': { type: 'lines', default: same('') },
38
38
  'security.sudo.execWheelOnly': { type: 'bool', default: same(false) },
39
39
  'networking.firewall.allowedTCPPortRanges': { type: 'listOf', default: same([]) },
40
+ 'networking.firewall.allowedUDPPortRanges': { type: 'listOf', default: same([]) },
40
41
  'users.mutableUsers': { type: 'bool', default: same(true) },
41
42
  'nix.settings.sandbox': { type: 'bool', default: same(true) },
42
43
  'nix.settings.trusted-users': { type: 'listOf', default: same(['root']) },
@@ -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 } 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
 
@@ -107,6 +108,14 @@ function loadNixExports(files, env = process.env) {
107
108
  return { exports, sources, problems, expected, trust: [...trust] };
108
109
  }
109
110
 
111
+ /** Names of the Haskell packages inside an imported Nix closure (derivations whose env carries Haskell build attributes). */
112
+ export function nixHaskellPackageNames(files, opts = {}) {
113
+ const c = nixClosureOf(files, opts);
114
+ if (!c || (c.refused && c.refused.length)) return [];
115
+ const nodes = (c.closure && c.closure.nodes) || [];
116
+ return [...new Set(nodes.filter((n) => n.pname && n.deriver && c.drvEnv[n.deriver] && HASKELL_ENV_HINTS.some((k) => k in c.drvEnv[n.deriver])).map((n) => n.pname))];
117
+ }
118
+
110
119
  /** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
111
120
  export function nixClosureOf(files, { now = Date.now() } = {}) {
112
121
  return memoized(files, 'nix-closure', () => {
@@ -132,10 +141,58 @@ export function configuredNixAdvisories(root, env = process.env, hackageDb = nul
132
141
  const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
133
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') : ''}` };
134
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
+ }
135
150
  const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
136
151
  return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
137
152
  }
138
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
+
139
196
  /** Closure statuses and findings, with every disclosure the import and the feed produced. */
140
197
  export function analyzeNixClosure(files, opts = {}) {
141
198
  return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
@@ -145,16 +202,21 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
145
202
  if (!c) return null;
146
203
  const hackage = configuredAdvisoryDb(scanRoot, env).db;
147
204
  const adv = configuredNixAdvisories(scanRoot, env, hackage);
205
+ if (!adv.data) adv.reason = `${adv.reason}${nixLiveFeedNote(env)}`;
206
+ const nm = configuredNixMeta(env);
148
207
  let usage = null; try { usage = collectUsage(files); } catch { usage = null; }
149
208
  let matched = null;
150
- 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) }); }
151
210
  const gaps = [];
152
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)' });
153
212
  for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
154
213
  for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
155
214
  for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
156
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.` });
157
- 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`})` });
158
220
  const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
159
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 };
160
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 {