@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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 (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -90,6 +90,7 @@ class Ctx {
90
90
  this.fnByQid = new Map();
91
91
  for (const f of Object.values(this.ir.perFile)) for (const fn of f.functions) this.fnByQid.set(fn.qid, fn);
92
92
  this.guardMemo = new Map();
93
+ this.handlerAst = new Map(); // handler IR fn -> { mod, rhss } for handlers lowered from an expression (named top-level ones are found by name)
93
94
  this.gaps = [];
94
95
  this.limits = [];
95
96
  }
@@ -271,6 +272,17 @@ function kidsIr(e) {
271
272
  function* walkIr(e, depth = 0) { if (!e || typeof e !== 'object' || depth > 80) return; yield e; for (const c of kidsIr(e)) yield* walkIr(c, depth + 1); }
272
273
  const identsIr = (e) => { const out = new Set(); for (const x of walkIr(e)) { if (x.kind === 'ident' && x.name) out.add(x.name); if (x.kind === 'member' && x.object) { for (const y of walkIr(x.object)) if (y.kind === 'ident') out.add(y.name); } } return out; };
273
274
 
275
+ function isSensitiveCall(ctx, c) {
276
+ if (SENSITIVE_PREFIX.some((p) => c.callee.startsWith(p))) return true;
277
+ const bare = c.callee.split('.').pop();
278
+ if (PERSIST_FNS.has(bare) && !/^(?:Prelude|Data|Web)\./.test(c.callee)) return true;
279
+ if (c.hs && c.hs.status === 'resolved' && c.hs.target) {
280
+ const sub = ctx.fnByQid.get(c.hs.target);
281
+ if (sub && callsOf(sub).some((x) => SENSITIVE_PREFIX.some((p) => (x.callee || '').startsWith(p)))) return true;
282
+ }
283
+ return false;
284
+ }
285
+
274
286
  // ── handler summary ─────────────────────────────────────────────────────────
275
287
  // Ordered call list (program order) + principal / id / ownership facts for one handler function IR.
276
288
  function handlerFacts(ctx, fn, opts = {}) {
@@ -294,16 +306,7 @@ function handlerFacts(ctx, fn, opts = {}) {
294
306
  if (c.hs && c.hs.status === 'resolved' && c.hs.target) return guardSummary(ctx, ctx.fnByQid.get(c.hs.target)).auth;
295
307
  return false;
296
308
  };
297
- const isSensitive = (c) => {
298
- if (SENSITIVE_PREFIX.some((p) => c.callee.startsWith(p))) return true;
299
- const bare = c.callee.split('.').pop();
300
- if (PERSIST_FNS.has(bare) && !/^(?:Prelude|Data|Web)\./.test(c.callee)) return true;
301
- if (c.hs && c.hs.status === 'resolved' && c.hs.target) {
302
- const sub = ctx.fnByQid.get(c.hs.target);
303
- if (sub && callsOf(sub).some((x) => SENSITIVE_PREFIX.some((p) => (x.callee || '').startsWith(p)))) return true;
304
- }
305
- return false;
306
- };
309
+ const isSensitive = (c) => isSensitiveCall(ctx, c);
307
310
  const guardIdx = ordered.findIndex((o) => isGuardCall(o.call));
308
311
  const sensIdx = ordered.findIndex((o) => isSensitive(o.call));
309
312
  // An INLINE guard: the handler itself reads a credential and, before any sensitive operation, rejects. Scotty's `status` only sets the
@@ -327,7 +330,7 @@ function handlerFacts(ctx, fn, opts = {}) {
327
330
  if (first !== null) reads.push({ line: first, kind: cp.kind, callee: `param:${cp.name}`, call: null });
328
331
  }
329
332
  reads.sort((x, y) => x.line - y.line);
330
- let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null;
333
+ let inlineIdx = -1, inlineKind = null, inlineLine = null, inlineCallee = null, inlineRejectCallee = null;
331
334
  for (let i = 0; i < reads.length && inlineIdx < 0; i++) {
332
335
  const rd = reads[i];
333
336
  if (!(rd.line <= sensLine)) break;
@@ -337,14 +340,14 @@ function handlerFacts(ctx, fn, opts = {}) {
337
340
  const r = allCalls[j];
338
341
  if (r === rd.call || r.line < rd.line || !isRejection(r) || !stopsHandler(r, allCalls)) continue;
339
342
  if (SCOTTY_STATUS.test(r.callee) && !haltsAfter(rd.line)) continue; // an identifier carries its enclosing node's line, so position is judged from the credential read
340
- inlineIdx = j; inlineKind = rd.kind; inlineLine = r.line; inlineCallee = rd.callee; break;
343
+ inlineIdx = j; inlineKind = rd.kind; inlineLine = r.line; inlineCallee = rd.callee; inlineRejectCallee = r.callee; break;
341
344
  }
342
345
  if (inlineIdx >= 0) break;
343
346
  // (b) a rejection that ends the handler by itself (WAI response handed back, throwError, permissionDenied, raiseStatus). A call in tail position
344
347
  // (the last statement, a case alternative) reports the enclosing line, not its own, so its line cannot be ordered against the sensitive
345
348
  // operation; the ordering that CAN be trusted is the credential read against the first sensitive operation, tested above.
346
349
  const hard = allCalls.findIndex((r) => r !== rd.call && isRejection(r) && !SCOTTY_STATUS.test(r.callee) && stopsHandler(r, allCalls));
347
- if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; break; }
350
+ if (hard >= 0) { inlineIdx = hard; inlineKind = rd.kind; inlineLine = allCalls[hard].line; inlineCallee = rd.callee; inlineRejectCallee = allCalls[hard].callee; break; }
348
351
  }
349
352
  const principals = new Set();
350
353
  for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
@@ -357,7 +360,7 @@ function handlerFacts(ctx, fn, opts = {}) {
357
360
  if (nm && /id$/i.test(nm)) idVars.add(a.target);
358
361
  }
359
362
  }
360
- return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
363
+ return { ordered, guardIdx, inlineIdx, inlineKind, inlineLine, inlineCallee, inlineRejectCallee, sensIdx, principals, idVars, conds, assigns, isGuardCall, isSensitive, fn };
361
364
  }
362
365
 
363
366
  function hasOwnership(ctx, facts) {
@@ -378,6 +381,181 @@ function hasOwnership(ctx, facts) {
378
381
  return null;
379
382
  }
380
383
 
384
+ // ── structural order of an inline guard (WAI, Servant, Yesod) ──────────────────
385
+ // For these frameworks the IR reports a call in tail position (the last statement, a case alternative) at the ENCLOSING line, so a line
386
+ // number cannot say whether the rejection runs before the first sensitive operation. The handler's own syntax tree can: this walks it in
387
+ // evaluation order (statements of a do block in sequence, a scrutinee or condition before its branches, `when`/`unless`/`if`/`case` as forks)
388
+ // and proves, per sensitive operation, whether a rejection that follows a credential read has already been passed on every path to it.
389
+ //
390
+ // * A branch is REJECTING when every path through it ends in a rejection reached after a credential read. The sibling branches of a rejecting
391
+ // branch, and everything after the conditional, are GUARDED. This is a statement about control flow only: it does not judge that the
392
+ // condition really examines the credential (the same laxity the line-based check had).
393
+ // * `throwError`, `permissionDenied` and `notAuthenticated` stop the handler wherever they appear. A WAI `respond`/`return` of a 401 stops it only in
394
+ // TAIL position (the value the handler hands back); a mid-block `respond` is followed by the next statement, so it is not a rejection.
395
+ // * Anything not understood (a rejection mixed with a sensitive operation inside one opaque expression, a body that is not available as
396
+ // source, a sensitive call the walk did not find) makes the order UNKNOWN, and unknown is reported as late, never as authenticated.
397
+ // Leaf expressions are classified by lowering them alone and reusing the classifiers above (credentialReadsOf, isRejection, stopsHandler,
398
+ // isSensitiveCall), so this adds ordering and no second definition of what a credential, a rejection or a sensitive call is.
399
+ const HARD_REJECT_HEAD = /^(?:throwError|permissionDenied|notAuthenticated)$/;
400
+ const SOFT_REJECT_HEAD = /^(?:respond|return|pure)$/;
401
+
402
+ function rhsBranches(rhs) {
403
+ if (!rhs) return [];
404
+ if (Array.isArray(rhs.guards) && rhs.guards.length) return rhs.guards.map((g) => ({ quals: g.quals || [], body: g.body }));
405
+ return [{ quals: [], body: rhs.body }];
406
+ }
407
+
408
+ function handlerSource(ctx, fn) {
409
+ const reg = ctx.handlerAst.get(fn);
410
+ if (reg) return reg;
411
+ const mod = ctx.modOf(fn.file);
412
+ const fun = mod && mod.groups.funs.find((f) => `${mod.name}.${f.name}` === fn.name);
413
+ return fun ? { mod, rhss: fun.clauses.map((c) => c.rhs) } : null;
414
+ }
415
+
416
+ function inlineOrder(ctx, fn, facts, opts) {
417
+ const src = handlerSource(ctx, fn);
418
+ if (!src) return { verdict: 'unknown', reason: 'the handler body is not available as source structure' };
419
+ const credNames = new Set((opts.credentialParams || []).map((c) => c.name));
420
+ const irSens = callsOf(fn).filter((c) => typeof c.callee === 'string' && isSensitiveCall(ctx, c)).length;
421
+ const T = { astSens: 0, failed: false, unordered: false };
422
+ const memo = new Map();
423
+ const localSens = new Map(); // a name bound by let/where to an expression that performs a sensitive operation -> how many
424
+ let seq = 0;
425
+ const mentions = (e, names) => { for (const x of walkAst(e)) if (x.t === 'var' && !x.qual && names.has(x.name)) return x.name; return null; };
426
+ const classify = (e) => {
427
+ if (memo.has(e)) return memo.get(e);
428
+ let out = { reads: false, rej: false, sensN: 0 };
429
+ try {
430
+ const f2 = lowerHandlerExpr(ctx.project, src.mod, e, `order${seq++}`, Number.isFinite(e.line) ? e.line : 1);
431
+ const calls = [...callsOf(f2), ...nullaryRejectionsOf(f2)].filter((c) => typeof c.callee === 'string');
432
+ out = {
433
+ // an argument-less credential read (`maybeAuthId`, `lookupBearerAuth`) is an identifier, not a call, when it is lowered on its own
434
+ reads: credentialReadsOf(f2).size > 0 || Object.values(f2.cfg.nodes).some((n) => exprsOfNode(n).some((r) => [...walkIr(r)].some((x) => x.kind === 'ident' && typeof x.name === 'string' && !!credentialKind({ callee: x.name, args: [] })))),
435
+ rej: calls.some((c) => isRejection(c) && !SCOTTY_STATUS.test(c.callee) && stopsHandler(c, calls)),
436
+ sensN: callsOf(f2).filter((c) => typeof c.callee === 'string' && isSensitiveCall(ctx, c)).length,
437
+ };
438
+ } catch { T.failed = true; }
439
+ // A lambda inside the expression is lifted out by the lowering, so the calls in its body are not among the expression's own: classify
440
+ // each lambda body on its own and fold it in (over-counting is the safe direction here).
441
+ memo.set(e, out);
442
+ for (const x of walkAst(e)) {
443
+ const bodies = x.t === 'lam' ? [x.body] : x.t === 'lamcase' ? (x.alts || []).flatMap((a) => rhsBranches(a.rhs).map((b) => b.body)) : [];
444
+ for (const b of bodies) { const sub = classify(b); out = { reads: out.reads || sub.reads, rej: out.rej || sub.rej, sensN: out.sensN + sub.sensN }; }
445
+ }
446
+ T.astSens += out.sensN;
447
+ memo.set(e, out);
448
+ return out;
449
+ };
450
+ const newAcc = () => ({ sens: [], guards: 0 });
451
+ const localMentions = (e) => { const out = []; for (const x of walkAst(e)) if (x.t === 'var' && !x.qual && localSens.has(x.name)) out.push(x.name); return out; };
452
+
453
+ const atom = (e, st, tail, acc) => {
454
+ const c = classify(e);
455
+ let s = st;
456
+ if (c.reads || (credNames.size && mentions(e, credNames))) s = { ...s, read: true, readMay: true };
457
+ let n = c.sensN;
458
+ for (const nm of localMentions(e)) n += localSens.get(nm);
459
+ for (let i = 0; i < n; i++) acc.sens.push({ guarded: s.guarded, readMay: s.readMay });
460
+ const sp = spine(e);
461
+ const hn = sp && sp.head ? sp.head.name : null;
462
+ if (c.rej && n === 0 && hn && (HARD_REJECT_HEAD.test(hn) || (tail && SOFT_REJECT_HEAD.test(hn))) && s.read) { acc.guards++; return { st: s, term: true }; }
463
+ if (c.rej && n > 0) T.unordered = true;
464
+ return { st: s, term: false };
465
+ };
466
+ // Run alternative branches. A branch that always rejects (after a read) guards its siblings and everything after the fork.
467
+ const fork = (st, kids, acc) => {
468
+ const rejecting = kids.map((k) => k(st, newAcc()).term);
469
+ const base = rejecting.some(Boolean) ? { ...st, guarded: true } : st;
470
+ const outs = kids.map((k, i) => k(rejecting[i] ? st : base, acc));
471
+ const live = outs.filter((o) => !o.term);
472
+ if (!live.length) return { st, term: true };
473
+ return { st: { read: live.every((o) => o.st.read), readMay: st.readMay || live.some((o) => o.st.readMay), guarded: live.every((o) => o.st.guarded) }, term: false };
474
+ };
475
+ const letDecls = (decls, st) => {
476
+ let s = st;
477
+ for (const d of decls || []) {
478
+ const rhs = d && d.t === 'clause' ? d.clause && d.clause.rhs : d && d.t === 'patbind' ? d.rhs : null;
479
+ if (!rhs) continue;
480
+ for (const b of rhsBranches(rhs)) {
481
+ const c = classify(b.body);
482
+ if (c.reads || (credNames.size && mentions(b.body, credNames))) s = { ...s, read: true, readMay: true };
483
+ if (c.sensN && d.t === 'clause' && d.name) localSens.set(d.name, (localSens.get(d.name) || 0) + c.sensN);
484
+ }
485
+ }
486
+ return s;
487
+ };
488
+ const walkBranch = (b, st, tail, acc) => {
489
+ let s = st;
490
+ for (const q of b.quals || []) {
491
+ if (q.t === 'qlet') { s = letDecls(q.decls, s); continue; }
492
+ const r = walk(q.e, s, false, acc);
493
+ if (r.term) return r;
494
+ s = r.st;
495
+ }
496
+ return walk(b.body, s, tail, acc);
497
+ };
498
+ const altKids = (alts, tail) => (alts || []).flatMap((alt) => rhsBranches(alt.rhs).map((b) => (s, a) => walkBranch(b, s, tail, a)));
499
+ const walkStmts = (stmts, st, tail, acc) => {
500
+ let s = st;
501
+ for (let i = 0; i < stmts.length; i++) {
502
+ const stmt = stmts[i];
503
+ const t = tail && i === stmts.length - 1;
504
+ let r;
505
+ if (stmt.t === 'sexpr') r = walk(stmt.e, s, t, acc);
506
+ else if (stmt.t === 'sbind') r = walk(stmt.e, s, false, acc);
507
+ else if (stmt.t === 'slet') r = { st: letDecls(stmt.decls, s), term: false };
508
+ else { T.unordered = true; r = { st: s, term: false }; }
509
+ if (r.term) return r;
510
+ s = r.st;
511
+ }
512
+ return { st: s, term: false };
513
+ };
514
+ const walk = (e0, st, tail, acc) => {
515
+ const e = unparen(e0);
516
+ if (!e) return { st, term: false };
517
+ switch (e.t) {
518
+ case 'do': return walkStmts(e.stmts || [], st, tail, acc);
519
+ case 'case': { const r = walk(e.scrut, st, false, acc); return r.term ? r : fork(r.st, altKids(e.alts, tail), acc); }
520
+ case 'lamcase': return fork(st, altKids(e.alts, tail), acc);
521
+ case 'if': { const r = walk(e.c, st, false, acc); return r.term ? r : fork(r.st, [(s, a) => walk(e.a, s, tail, a), (s, a) => walk(e.b, s, tail, a)], acc); }
522
+ case 'multiif': return fork(st, (e.guards || []).map((g) => (s, a) => walkBranch({ quals: g.quals || [], body: g.body }, s, tail, a)), acc);
523
+ case 'lam': return walk(e.body, st, tail, acc);
524
+ case 'let': return walk(e.body, letDecls(e.decls, st), tail, acc);
525
+ default: break;
526
+ }
527
+ if (e.t === 'app' || (e.t === 'op' && e.op === '$')) {
528
+ const sp = spine(e);
529
+ if (sp && sp.head && /^(?:when|unless)$/.test(sp.head.name) && sp.args.length >= 2) {
530
+ const r = walk(sp.args[0], st, false, acc);
531
+ return r.term ? r : fork(r.st, [(s, a) => walk(sp.args[1], s, false, a), (s) => ({ st: s, term: false })], acc);
532
+ }
533
+ }
534
+ if (e.t === 'op' && (e.op === '>>' || e.op === '*>' || e.op === '>>=')) {
535
+ const l = walk(e.l, st, false, acc);
536
+ return l.term ? l : walk(e.r, l.st, tail, acc);
537
+ }
538
+ if (e.t === 'op' && e.op === '=<<') {
539
+ const r = walk(e.r, st, false, acc);
540
+ return r.term ? r : walk(e.l, r.st, tail, acc);
541
+ }
542
+ return atom(e, st, tail, acc);
543
+ };
544
+
545
+ const acc = newAcc();
546
+ let s0 = { read: false, readMay: false, guarded: false };
547
+ for (const rhs of src.rhss) s0 = letDecls(rhs.where || [], s0);
548
+ fork(s0, src.rhss.flatMap(rhsBranches).map((b) => (s, a) => walkBranch(b, s, true, a)), acc);
549
+ if (T.failed) return { verdict: 'unknown', reason: 'part of the handler could not be analysed' };
550
+ if (T.unordered) return { verdict: 'unknown', reason: 'a rejection and a sensitive operation sit in one expression whose order is not modelled' };
551
+ if (T.astSens < irSens) return { verdict: 'unknown', reason: 'a sensitive operation was not found in the handler source structure' };
552
+ if (acc.sens.length === 0 && irSens === 0) return { verdict: 'before', reason: 'no sensitive operation to order' };
553
+ const unguarded = acc.sens.filter((x) => !x.guarded);
554
+ if (!unguarded.length && acc.guards > 0) return { verdict: 'before' };
555
+ if (unguarded.length && unguarded.every((x) => x.readMay)) return { verdict: 'after', reason: 'a sensitive operation runs after the credential read and before any rejection' };
556
+ return { verdict: 'unknown', reason: 'the order of the rejection and the sensitive operations could not be established' };
557
+ }
558
+
381
559
  // ── route record ─────────────────────────────────────────────────────────────
382
560
  function newRoute(r) { return { auth: { status: 'unknown', kind: null, evidence: [] }, role: { status: 'none', evidence: [] }, params: [], uploads: false, limits: [], ...r }; }
383
561
 
@@ -403,7 +581,11 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
403
581
  (sub.kinds || []).forEach((k) => R.credentialKinds.add(k));
404
582
  }
405
583
  } else if (facts.inlineIdx >= 0) {
406
- R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
584
+ // Scotty reports its halting points reliably. For WAI, Servant and Yesod the candidate (a credential read and a rejection that ends the
585
+ // handler) is only credited once the STRUCTURE of the handler proves the rejection precedes every sensitive operation.
586
+ const ord = R.framework === 'scotty' ? { verdict: 'before' } : inlineOrder(ctx, handlerFn, facts, opts);
587
+ if (ord.verdict === 'before') R.auth = { status: 'authenticated', kind: facts.inlineKind, evidence: [{ kind: 'inline-guard', callee: facts.inlineCallee, line: facts.inlineLine }] };
588
+ else R.auth = { status: 'late', kind: null, evidence: [{ kind: 'late-guard', callee: facts.inlineRejectCallee || facts.inlineCallee, line: facts.inlineLine, order: ord.verdict, reason: ord.reason }] };
407
589
  } else R.auth = { status: 'none', kind: null, evidence: [] };
408
590
  // role
409
591
  const roleCall = facts.ordered.find((o) => ROLE_CALL.test(o.call.callee || ''));
@@ -730,14 +912,14 @@ export function analyzeHaskellWeb(files, opts = {}) {
730
912
  // a handler bound in the enclosing `where` (Servant servers usually are)
731
913
  const local = (p.whereDecls || []).find((d) => d.name === head.name && !head.qual);
732
914
  if (local && local.clause && local.clause.rhs && local.clause.rhs.body && !(local.clause.rhs.guards && local.clause.rhs.guards.length)) {
733
- try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
915
+ try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, local.clause.rhs.body, `${p.framework}-${head.name}`, local.line || p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [local.clause.rhs] }); handlerName = head.name; paramNames = paramsOf(local.clause); } catch { handlerFn = null; }
734
916
  } else {
735
917
  const q = ctx.qualify(p.mod, head);
736
918
  if (q && q.qid) { handlerFn = ctx.fnFor(q); handlerName = q.name; const f = topFun(p.mod, head.name); if (f && f.clauses[0]) paramNames = paramsOf(f.clauses[0]); }
737
919
  else if (q) handlerName = q.name;
738
920
  }
739
921
  } else {
740
- try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); handlerName = '<inline>'; } catch { handlerFn = null; }
922
+ try { handlerFn = lowerHandlerExpr(ctx.project, p.mod, p.handlerAst, `${p.framework}-${p.line}`, p.line); ctx.handlerAst.set(handlerFn, { mod: p.mod, rhss: [{ guards: null, body: p.handlerAst, where: [] }] }); handlerName = '<inline>'; } catch { handlerFn = null; }
741
923
  }
742
924
  }
743
925
  R.handler = handlerFn ? { kind: handlerName === '<inline>' ? 'inline' : 'named', name: handlerName } : { kind: 'unknown', name: handlerName };
@@ -851,7 +1033,7 @@ function emitFindings(ctx, result, yesodAuthz) {
851
1033
  if (!R.handler || R.handler.kind === 'unknown') continue;
852
1034
  const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
853
1035
  const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
854
- if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
1036
+ if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: R.auth.evidence[0].order === 'unknown' ? `the rejection ${R.auth.evidence[0].callee} cannot be shown to run before the first sensitive operation (${R.auth.evidence[0].reason})` : `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
855
1037
  else if (R.auth.status === 'none' && (writes || privileged) && R.sensitive !== false) {
856
1038
  if (!(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: R.method === 'ANY' ? 'any method' : 'state-changing method' });
857
1039
  }
@@ -340,7 +340,7 @@ function makeEffectiveCheck(entry) {
340
340
  export async function validateNixFix(finding, o) {
341
341
  const plan = planNixFix(finding, o.files, o);
342
342
  if (!plan.ok) return { status: plan.status || 'unsupported', applied: false, tier: plan.tier, reason: plan.reason, proposal: plan.proposal || null, ...plan };
343
- const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
343
+ const res = await runFixLifecycle({ plan, files: o.files, finding, matchKey, rescan: o.rescan || rescanNix, syntax: syntaxGate, behaviorCheck: o.behaviorCheck, effectiveCheck: makeEffectiveCheck(entryOf(o.files, o)), writeFile: o.writeFile, preWrite: o.preWrite, witness: o.witness, requireWitness: o.requireWitness, apply: o.apply, root: o.root });
344
344
  return { ...res, consequences: plan.consequences || [], tier: res.status === 'blocked' ? TIERS.blocked : plan.tier, explanation: plan.explanation };
345
345
  }
346
346
 
@@ -69,13 +69,16 @@ export function resolvedHackageComponents(files) {
69
69
  const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
70
70
  const direct = new Set((g.declared || []).map((d) => d.name));
71
71
  const components = [];
72
+ // Boot libraries are not dependencies to match, but the plan states the exact version the compiler provides: the compiler-advisory
73
+ // grouping decides a boot-library advisory from it (and from the compiler-id) instead of from a declared range.
74
+ const bootLibraries = (g.units || []).filter((u) => u.origin === 'compiler' && u.name && u.version).map((u) => ({ name: u.name, version: u.version, versionSource: { file: r.file, line: null } }));
72
75
  for (const u of g.units) {
73
76
  if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
74
77
  const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
75
78
  const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
76
79
  components.push({ ecosystem: 'hackage', name: u.name, version: u.version, declaredRange: null, resolution: 'plan', scope, engineScope: SCOPE[scope] || 'required', target: null, componentKind: u.componentKind || null, manifest: r.file, line: null, ghcComponent: false, direct: direct.has(u.name), unitId: u.id, origin: u.origin, versionSource: { file: r.file, line: null } });
77
80
  }
78
- return { components, gaps, summary };
81
+ return { components, bootLibraries, compiler: g.compiler || null, gaps, summary };
79
82
  }
80
83
 
81
84
  // ── Nix closure ──────────────────────────────────────────────────────────────────
@@ -92,7 +92,7 @@ export function runNixScriptWitness(w, { timeoutMs = DEFAULT_TIMEOUT_MS, force }
92
92
  // 1. a witness that does not parse proves nothing in either direction
93
93
  const syn = runConfined([shellBin, '-n', 'witness.sh'], { root, timeoutMs, force });
94
94
  if (syn.status === 'disabled' || syn.status === 'error') return { ...base, backend: syn.backend, status: 'invalid', reason: `the sandbox could not run the syntax check (${syn.backend}): ${String(syn.stderr || '').trim() || 'no detail'}` };
95
- if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget' };
95
+ if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget', timedOut: true };
96
96
  if (syn.exitCode !== 0) return { ...base, backend: syn.backend, status: 'invalid', reason: `syntax error in the generated witness: ${String(syn.stderr || '').trim().split('\n')[0] || 'shell rejected it'}` };
97
97
  // 2. the real run
98
98
  const r = runConfined([shellBin, 'witness.sh'], { root, timeoutMs, force });
package/src/mcp/CLAUDE.md CHANGED
@@ -12,8 +12,8 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
12
12
  | `find_rule_module` | ✓ | reads `scanner/src/{sast,posture}/` to answer "which file detects CWE-X / family Y" |
13
13
  | `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
14
14
  | `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
15
- | `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
16
- | `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
15
+ | `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt. Also returns `verificationRecord`, the shared version-1 verification record (X-201; additive, `apply_fix` returns it too): a static re-scan alone is `not-run`, never confirmed |
16
+ | `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup). With `plan_digest` it applies a MULTI-FILE NixOS option fix (see "Multi-file apply_fix" below): all files or none, one backup per file, one history group |
17
17
  | `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
18
18
  | `read_scratchpad` | ✓ | paginated read of scratchpad files |
19
19
  | `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
@@ -42,6 +42,8 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
42
42
  | Path-escape refusal | `tools.js::_confine` lexical check before any fs call |
43
43
  | Reserved-write paths | `tools.js::RESERVED_WRITE_*` — `.git/`, `.github/`, `.gitlab/`, `.circleci/`, `.buildkite/`, `.agentic-security/`, `node_modules/`, `.terraform/`, `.aws/`, `k8s/`, manifest basenames, `*.tf`, `docker-compose.yml` |
44
44
  | HMAC integrity on findings | `posture/integrity.js` — per-install random key at `$XDG_CONFIG_HOME/agentic-security/scan-key`. **Not** hostname-derived. |
45
+ | Multi-file plan binding | `tools.js::_multiFilePlanDigest` / `_applyMultiFilePlan`: `apply_fix` never accepts file content for a multi-file plan; it recomputes the plan from the signed finding + live tree and compares a digest (finding id, stableId, every path, SHA-256 of every pre/post image) with the caller's `plan_digest`. The lifecycle's `preWrite` hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written |
46
+ | Multi-file confinement | `tools.js::_checkMultiFileTargets`: EVERY file of the plan gets clean root-relative form (no absolute, `..`, `.`, backslash, NUL, empty segment), `_confine` (no symlink leaf, no escape), a no-symlinked-parent check (realpath must equal the lexical path), the reserved-write list, existence, no duplicates, at most 8 files. One bad file refuses the whole plan |
45
47
  | Patches pass through unredacted | `tools.js` synthesize_fix / apply_fix — premortem-derived. Patches are not findings; redacting them silently corrupts valid fixes. |
46
48
  | Secret redaction on findings | `redact.js` — applied to snippet/description/title/vuln/remediation/trace |
47
49
  | Audit log | `audit.js` — NDJSON, hash-chained, at `.agentic-security/mcp-audit.log`. Set `$AGENTIC_SECURITY_AUDIT_WEBHOOK=<url>` to also fire-and-forget POST every entry to a remote witness — closes the full-file-rewrite blind spot. Failures land in `mcp-audit.remote-errors.log` and never block a tool call. |
@@ -70,5 +72,42 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
70
72
 
71
73
  `scan_diff`, `verify_fix` and `synthesize_fix` add the import closure of a Haskell or Nix file and the manifests as context
72
74
  (`language/context.js` `withLanguageContext`, bounded in files and bytes, read-only) and still report only the files asked
73
- about. The tool that previews a fix for these files uses the language fixers; `apply_fix` is unchanged (its generic confined-write
74
- path), and the language-aware apply with backup and `undo` is the CLI's `fix --apply`.
75
+ about. The tool that previews a fix for these files uses the language fixers. A single-file `apply_fix` is unchanged (its generic
76
+ confined-write path); the language-aware apply with backup and `undo` for everything else is the CLI's `fix --apply`.
77
+
78
+ ## Multi-file `apply_fix` (NixOS option fixes, `plan_digest`)
79
+
80
+ A NixOS option is judged on the effective configuration, so one fix can edit several files (every definition at the winning
81
+ priority). Flow: `synthesize_fix` returns `languageFix.multiFile` (the files), `languageFix.edits` (per file: path + SHA-256 of
82
+ the before and after image, **no content**) and `languageFix.planDigest` after running the verification gates read-only. The
83
+ caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
84
+ high-impact change).
85
+
86
+ Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
87
+ re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
88
+ exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
89
+ `stale:true` with no hint of the expected value. Order of checks: `confirm:true` -> last-scan HMAC -> finding exists -> not shadow
90
+ -> digest format -> Nix finding with stableId -> plan exists and has 2+ files -> digest equal -> every file confined and not
91
+ reserved -> high-impact approval (shared `_highImpactApprovalRefusal`, same code as the `patch` branch) -> lifecycle
92
+ (`validateNixFix`, apply): syntax, rescan (original gone, nothing new at medium+), effective-value gate, then `preWrite`, then
93
+ `writeManyWithBackup` (checks every file unchanged since planning and backs every file up before the first write). History is
94
+ one `languageGroupId` group: `undo` / `undoLast` revert all files or none.
95
+
96
+ Results are non-leaky: absolute paths are scrubbed to `<root>` and passed through `redactString`. A failed write is reported as
97
+ `applied:false` with `rolledBack` and `rollbackIncomplete` (+ `restoreManually` file names) honestly; an incomplete rollback is
98
+ never reported as success.
99
+
100
+ What this deliberately does NOT do: no caller-supplied multi-file `patch` (would reopen the plan-binding problem), no Haskell
101
+ multi-file plans (the Haskell planner produces none), no `flake.lock` relock plans (`.lock` is on the reserved-write list and
102
+ the relock path is not a finding fix), no fix-attempt-budget enforcement (the language lifecycle records attempts but, as in the
103
+ CLI, does not enforce a cap), and no `fixMeta` honesty gate (that gate judges a caller's residual/verdict claims, which this
104
+ path does not take). The `preWrite` digest re-check is defence in depth over a deterministic planner and has no independent
105
+ test that fails when it is removed (the earlier digest comparison is the pinned control).
106
+
107
+ ## Verification view (X-206)
108
+
109
+ `explain_finding` (when the finding in `last-scan.json` carries a `verificationRecord`), `verify_fix` and `apply_fix` add
110
+ `verificationView`, the shared projection from `posture/verification/projection.js`, beside the existing `verificationRecord`.
111
+ It is the same object the JSON report, the text reports and the autopilot response carry for the same finding. Additive: a finding
112
+ without a record returns exactly what it did. The key is `verificationView`, not `verification` (that name belongs to the
113
+ producer/verifier separation record). See `docs/guides/verification-schema-migration.md`.