@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9

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 (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
@@ -98,7 +98,22 @@ function lhsPath(n) {
98
98
  if (n.type === 'ThisExpression') return '_this_';
99
99
  if (n.type === 'MemberExpression') {
100
100
  const base = lhsPath(n.object);
101
- const prop = n.computed ? '*' : (n.property?.name || '*');
101
+ // Stage 3 correctness audit (detection depth, path-feasibility): a
102
+ // computed WRITE with a literal key (`obj['secret'] = tainted`) used
103
+ // to collapse straight to the wildcard '*' — never trying to extract
104
+ // the literal, unlike exprOf's MemberExpression case just above,
105
+ // which DOES extract it for reads. That asymmetry meant a tainted
106
+ // write via bracket notation with a literal key produced access path
107
+ // "obj.*", while any later read of that same key (`obj.secret` or
108
+ // `obj['secret']`) resolves via exprOf to the specific path
109
+ // "obj.secret" — isCoveredBy has no wildcard semantics (`'*'` is a
110
+ // literal property name here, not a match-anything token), so the two
111
+ // paths never matched and the taint was silently unreachable from any
112
+ // correctly-computed read. Mirrors exprOf's extraction exactly so a
113
+ // write and a read of the same literal key always agree.
114
+ const prop = n.computed
115
+ ? (n.property?.value != null ? String(n.property.value) : '*')
116
+ : (n.property?.name || '*');
102
117
  if (!base) return null;
103
118
  return base + '.' + prop;
104
119
  }
@@ -138,16 +153,25 @@ export function parseJsFile(file, code) {
138
153
  const exitId = nextNodeId();
139
154
  const fn = {
140
155
  qid, name: name || 'anon', line,
156
+ // Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
157
+ // ['arg1', 'arg2', ...]") — every other parser (py, cs, rb, cpp, ...)
158
+ // already emits this shape. This one used to emit {name,kind[,props]}
159
+ // objects instead, which every consumer (access-paths.js's
160
+ // isCoveredBy, summaries.js's paramNames.indexOf, entryStateFromCall,
161
+ // the k=2 pass's `new Set(fn.params)`) silently failed to match against
162
+ // — Set/string-equality checks against an object never succeed, so
163
+ // mutated-parameter taint and context-sensitive entry states were both
164
+ // unconditionally inert for every JS/TS function. Nothing in src/
165
+ // reads a param's .kind or .props, so the richer shape bought nothing.
141
166
  params: (params || []).map(p => {
142
167
  if (!p) return null;
143
- if (p.type === 'Identifier') return { name: p.name, kind: 'ident' };
144
- if (p.type === 'ObjectPattern') return { name: '<obj>', kind: 'object-pattern',
145
- props: p.properties.map(pp => ({
146
- key: pp.key?.name || (pp.key?.value != null ? String(pp.key.value) : '*'),
147
- alias: lhsPath(pp.value),
148
- })) };
149
- if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return { name: p.left.name, kind: 'ident' };
150
- if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return { name: p.argument.name, kind: 'rest' };
168
+ if (p.type === 'Identifier') return p.name;
169
+ if (p.type === 'ObjectPattern') return '<obj>';
170
+ if (p.type === 'ArrayPattern') return '<arr>';
171
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return p.left.name;
172
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'ObjectPattern') return '<obj>';
173
+ if (p.type === 'AssignmentPattern' && p.left?.type === 'ArrayPattern') return '<arr>';
174
+ if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return p.argument.name;
151
175
  return null;
152
176
  }).filter(Boolean),
153
177
  cfg: { entry: entryId, exit: exitId, nodes: new Map() },
@@ -189,6 +213,38 @@ export function parseJsFile(file, code) {
189
213
  return node.id;
190
214
  };
191
215
 
216
+ // Babel visits an IfStatement's consequent then its alternate as ordinary
217
+ // children of the same enter/exit pair, with no boundary hook between
218
+ // them — so fn._cursor was never reset before the alternate was
219
+ // traversed: the alternate's first node was linked as a successor of the
220
+ // CONSEQUENT's tail (a false predecessor edge corrupting any taint-state
221
+ // reasoning across the join) rather than a true second branch off the
222
+ // condition (also silently dropping the condition's "false" outgoing
223
+ // edge, which made applyPathFeasibility's constant-condition pruning
224
+ // treat the `if` as unconditional and delete its only edge).
225
+ //
226
+ // Called from the generic `Statement` visitor below AND from the top of
227
+ // every handler whose node type can itself be the direct (unbraced) root
228
+ // of an if's alternate (ReturnStatement, ThrowStatement, IfStatement for
229
+ // else-if chains, the loop statements, TryStatement) — Babel merges
230
+ // alias-derived and type-specific visitors for the same node but runs the
231
+ // type-specific one FIRST, so relying on the `Statement` alias alone
232
+ // misses every alternate root that also has its own specific handler.
233
+ // The one-shot flag makes the (redundant, second) `Statement` firing a
234
+ // safe no-op once the type-specific handler already did the reset.
235
+ const maybeResetAtAlternateBoundary = (fn, path) => {
236
+ const parent = path.parentPath && path.parentPath.node;
237
+ if (!parent || parent.type !== 'IfStatement' || parent.alternate !== path.node) return;
238
+ if (path.node._alternateBoundaryHandled) return;
239
+ path.node._alternateBoundaryHandled = true;
240
+ // fn._cursor is still the consequent's tail (or the condition node
241
+ // itself, if the consequent added none) — converge it into the join
242
+ // before abandoning it, then start the alternate from the condition,
243
+ // exactly like the consequent did.
244
+ linkCfg(fn, fn._cursor, parent._asJoin);
245
+ fn._cursor = parent._asCond;
246
+ };
247
+
192
248
  const recordWrite = (fn, target, source, nodeId) => {
193
249
  if (!target || typeof target !== 'string') return;
194
250
  if (!fn.writes.has(target)) fn.writes.set(target, []);
@@ -266,17 +322,45 @@ export function parseJsFile(file, code) {
266
322
  const id = lhsPath(path.node.id);
267
323
  if (!id) return;
268
324
  const initExpr = exprOf(path.node.init);
269
- const nodeId = nextNodeId();
270
325
  const line = path.node.loc?.start?.line || 0;
271
- addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
272
- if (typeof id === 'string') recordWrite(fn, id, initExpr, nodeId);
326
+ if (typeof id === 'string') {
327
+ const nodeId = nextNodeId();
328
+ addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
329
+ recordWrite(fn, id, initExpr, nodeId);
330
+ return;
331
+ }
332
+ // Destructuring: `const {a, b: renamed} = obj;` / `const [a, b] = arr;`.
333
+ // Emit one REAL 'assign' CFG node per bound name — not just a
334
+ // recordWrite() bookkeeping entry — because the taint engine's
335
+ // step() 'assign' case reads `node.target` directly and requires
336
+ // a plain string (`typeof node.target === 'string'`); the single
337
+ // node this used to emit had target=the whole {kind:'object-
338
+ // pattern'|'array-pattern', ...} OBJECT, which step() always
339
+ // treated as `target=null` — so taint from `obj`/`arr` never
340
+ // reached ANY destructured binding, for every project that uses
341
+ // this extremely common pattern. fn.writes/recordWrite (used
342
+ // below too) is bookkeeping nothing in dataflow/ ever reads;
343
+ // only real CFG nodes matter to the walk.
273
344
  if (id && typeof id === 'object' && id.kind === 'object-pattern') {
274
- // x = { foo: a, bar: b } — emit one write per property.
275
345
  for (const p of id.props) {
276
346
  const alias = typeof p.alias === 'string' ? p.alias : null;
277
347
  if (!alias) continue;
278
- recordWrite(fn, alias, { kind: 'member', object: initExpr, prop: p.key }, nodeId);
348
+ const memberSrc = { kind: 'member', object: initExpr, prop: p.key };
349
+ const nodeId = nextNodeId();
350
+ addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
351
+ recordWrite(fn, alias, memberSrc, nodeId);
279
352
  }
353
+ return;
354
+ }
355
+ if (id && typeof id === 'object' && id.kind === 'array-pattern') {
356
+ id.elements.forEach((el, i) => {
357
+ const alias = typeof el === 'string' ? el : null;
358
+ if (!alias) return; // elision (`const [, b] = arr`) or nested pattern — not modeled
359
+ const memberSrc = { kind: 'member', object: initExpr, prop: String(i) };
360
+ const nodeId = nextNodeId();
361
+ addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
362
+ recordWrite(fn, alias, memberSrc, nodeId);
363
+ });
280
364
  }
281
365
  },
282
366
 
@@ -298,7 +382,29 @@ export function parseJsFile(file, code) {
298
382
  const parent = path.parent;
299
383
  if (parent && (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression')) return;
300
384
  const calleeExpr = exprOf(path.node.callee);
301
- const args = (path.node.arguments || []).map(exprOf);
385
+ // An inline arrow/function-expression argument (`arr.map(x => ...)`)
386
+ // becomes {kind:'function-value', qid} instead of exprOf's generic
387
+ // {kind:'unknown'} fallback (exprOf has no case for either node
388
+ // type) — dataflow/higher-order.js's calleeIsResolvableCallback and
389
+ // engine.js's higher-order-invocation push site both special-case
390
+ // 'function-value', but nothing ever produced one for JS/TS, so
391
+ // higher-order taint flow only ever worked for a by-reference
392
+ // callback (`arr.map(processItem)`), never the far more common
393
+ // inline-callback shape. The qid is computed to match EXACTLY what
394
+ // this same node's own ArrowFunctionExpression/FunctionExpression
395
+ // visitor (below) will independently compute when Babel's
396
+ // traversal reaches it: for a function literal passed directly as
397
+ // a call argument, none of that visitor's naming heuristics
398
+ // (VariableDeclarator/AssignmentExpression/ObjectProperty parent)
399
+ // match, so it always resolves to name 'anon' scoped under the
400
+ // CURRENT function — exactly what's available here.
401
+ const args = (path.node.arguments || []).map(a => {
402
+ if (a && (a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression')) {
403
+ const argLine = a.loc?.start?.line || 1; // matches enterFn's own fallback
404
+ return { kind: 'function-value', qid: fnQid(file, fn.name, 'anon', argLine) };
405
+ }
406
+ return exprOf(a);
407
+ });
302
408
  const line = path.node.loc?.start?.line || 0;
303
409
  const nodeId = nextNodeId();
304
410
  addNode(fn, { id: nodeId, kind: 'call', callee: calleeExpr, args, line, succ: [], pred: [] });
@@ -313,6 +419,7 @@ export function parseJsFile(file, code) {
313
419
 
314
420
  ReturnStatement(path) {
315
421
  const fn = currentFn(); if (!fn) return;
422
+ maybeResetAtAlternateBoundary(fn, path);
316
423
  const expr = path.node.argument ? exprOf(path.node.argument) : null;
317
424
  const nodeId = nextNodeId();
318
425
  const line = path.node.loc?.start?.line || 0;
@@ -322,12 +429,30 @@ export function parseJsFile(file, code) {
322
429
  linkCfg(fn, nodeId, fn.cfg.exit);
323
430
  },
324
431
 
432
+ // Fires for every statement node, via Babel's built-in "Statement"
433
+ // alias — the fallback path for whichever concrete type an if's
434
+ // `alternate` turns out to be when that type has no specific
435
+ // handler of its own (BlockStatement, ExpressionStatement,
436
+ // VariableDeclaration, ...). Types that DO have a specific handler
437
+ // below (ReturnStatement, IfStatement, the loop statements,
438
+ // TryStatement) call `maybeResetAtAlternateBoundary` themselves,
439
+ // since Babel runs a node's type-specific visitor BEFORE its
440
+ // alias-derived one for the same node — relying on this alone would
441
+ // miss those cases entirely.
442
+ Statement: {
443
+ enter(path) {
444
+ const fn = currentFn(); if (!fn) return;
445
+ maybeResetAtAlternateBoundary(fn, path);
446
+ },
447
+ },
448
+
325
449
  IfStatement: {
326
450
  enter(path) {
327
451
  // We model branches by inserting a noop "join" after the if; both
328
452
  // branches link to it. Without this, the linear cursor model would
329
453
  // miss that statements after the if are reachable from either branch.
330
454
  const fn = currentFn(); if (!fn) return;
455
+ maybeResetAtAlternateBoundary(fn, path);
331
456
  const condNodeId = nextNodeId();
332
457
  const joinId = nextNodeId();
333
458
  const line = path.node.loc?.start?.line || 0;
@@ -335,7 +460,6 @@ export function parseJsFile(file, code) {
335
460
  fn.cfg.nodes.set(joinId, { id: joinId, kind: 'noop', succ: [], pred: [], line });
336
461
  path.node._asJoin = joinId;
337
462
  path.node._asCond = condNodeId;
338
- path.node._asBranchSavedCursor = fn._cursor; // == condNodeId
339
463
  },
340
464
  exit(path) {
341
465
  const fn = currentFn(); if (!fn) return;
@@ -344,9 +468,10 @@ export function parseJsFile(file, code) {
344
468
  if (!joinId || !condId) return;
345
469
  // The visitor visited the body of the if — Babel's body visit ran
346
470
  // *after* the enter(), so fn._cursor now points to the tail of the
347
- // consequent. Connect it to the join, then if no else branch
348
- // existed, connect the cond directly to the join (representing
349
- // the "false" edge).
471
+ // consequent (or, thanks to maybeResetAtAlternateBoundary, of the
472
+ // alternate when one exists). Connect it to the join, then if no
473
+ // else branch existed, connect the cond directly to the join
474
+ // (representing the "false" edge).
350
475
  linkCfg(fn, fn._cursor, joinId);
351
476
  if (!path.node.alternate) linkCfg(fn, condId, joinId);
352
477
  fn._cursor = joinId;
@@ -359,6 +484,7 @@ export function parseJsFile(file, code) {
359
484
  'WhileStatement|ForStatement|DoWhileStatement|ForInStatement|ForOfStatement': {
360
485
  enter(path) {
361
486
  const fn = currentFn(); if (!fn) return;
487
+ maybeResetAtAlternateBoundary(fn, path);
362
488
  const headerId = nextNodeId();
363
489
  const exitId = nextNodeId();
364
490
  const line = path.node.loc?.start?.line || 0;
@@ -379,11 +505,16 @@ export function parseJsFile(file, code) {
379
505
  },
380
506
 
381
507
  TryStatement: {
382
- enter() { /* approximate try/catch as sequential — taint flows through both */ },
508
+ enter(path) {
509
+ const fn = currentFn(); if (!fn) return;
510
+ maybeResetAtAlternateBoundary(fn, path);
511
+ /* approximate try/catch as sequential — taint flows through both */
512
+ },
383
513
  },
384
514
 
385
515
  ThrowStatement(path) {
386
516
  const fn = currentFn(); if (!fn) return;
517
+ maybeResetAtAlternateBoundary(fn, path);
387
518
  const expr = exprOf(path.node.argument);
388
519
  const nodeId = nextNodeId();
389
520
  const line = path.node.loc?.start?.line || 0;
@@ -19,6 +19,7 @@
19
19
 
20
20
  import * as crypto from 'node:crypto';
21
21
  import { callSitesFromCfg } from './call-sites.js';
22
+ import { matchBalancedCall } from './balanced-call.js';
22
23
 
23
24
  const FUNC_RE = new RegExp(
24
25
  '(?:^|[\\n;{}]|<\\?php|<\\?)\\s*' +
@@ -66,6 +67,35 @@ function _splitStatements(body) {
66
67
  function _lowerExpr(text) {
67
68
  const s = String(text || '').trim();
68
69
  if (!s) return { kind: 'unknown' };
70
+ // Stage 3 correctness audit (detection depth, per-language-IR): PHP
71
+ // double-quoted strings interpolate variables directly
72
+ // ("SELECT ... WHERE id=$id", "hi {$user->name}") — single-quoted
73
+ // strings never do. This must run BEFORE the generic string-literal
74
+ // fallback just below, which otherwise treated ANY quoted string
75
+ // (including a double-quoted one containing a live variable) as an
76
+ // opaque clean literal — silently dropping the interpolated variable's
77
+ // taint. This is exactly `"SELECT ... WHERE id=$id"`, one of the most
78
+ // common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
79
+ // `$var[key]`) and complex (`{$expr}`) interpolation forms are both
80
+ // lowered into a template, same shape as the `.`-concat branch below.
81
+ if (/^"/.test(s) && s.includes('$')) {
82
+ const inner = s.slice(1, -1);
83
+ const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
84
+ let lastIndex = 0;
85
+ const parts = [];
86
+ let matched = false;
87
+ let m;
88
+ while ((m = re.exec(inner)) !== null) {
89
+ matched = true;
90
+ if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
91
+ parts.push(_lowerExpr(m[1] !== undefined ? m[1] : m[2]));
92
+ lastIndex = re.lastIndex;
93
+ }
94
+ if (matched) {
95
+ if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
96
+ return { kind: 'tpl', parts };
97
+ }
98
+ }
69
99
  if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
70
100
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
71
101
  if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
@@ -81,17 +111,22 @@ function _lowerExpr(text) {
81
111
  }
82
112
  // Variable
83
113
  if (/^\$[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
84
- // Method call: $obj->method(args) or ClassName::method(args)
85
- const methodCall = s.match(/^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)\s*\((.*)\)\s*$/s);
114
+ // Method call: $obj->method(args) or ClassName::method(args).
115
+ // matchBalancedCall finds the paren that actually balances the FIRST
116
+ // '(' — not the greedy-to-end-of-string match the old `/\((.*)\)\s*$/`
117
+ // used, which corrupted the argument text for a chained call
118
+ // (`sanitize($x)->trim()` produced args="$x)->trim(", which then fell
119
+ // through to {kind:'unknown'} and silently dropped $x).
120
+ const methodCall = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
86
121
  if (methodCall) {
87
- const callee = methodCall[1].replace(/->/g, '.').replace(/::/g, '.');
88
- const args = _splitTopLevelCommas(methodCall[2]).map(_lowerExpr);
122
+ const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
123
+ const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
89
124
  return { kind: 'call', callee, args };
90
125
  }
91
126
  // Function call: func(args)
92
- const funcCall = s.match(/^([A-Za-z_][\w]*)\s*\((.*)\)\s*$/s);
127
+ const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
93
128
  if (funcCall) {
94
- return { kind: 'call', callee: funcCall[1], args: _splitTopLevelCommas(funcCall[2]).map(_lowerExpr) };
129
+ return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
95
130
  }
96
131
  // Concat with .
97
132
  if (s.includes('.') && /["'\$]/.test(s)) {
@@ -170,10 +205,10 @@ function _lowerStmt(stmt, line) {
170
205
  return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
171
206
  }
172
207
  // Statement-form call
173
- const call = s.match(/^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)\s*\((.*)\)\s*$/s);
208
+ const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
174
209
  if (call) {
175
- const callee = call[1].replace(/->/g, '.').replace(/::/g, '.');
176
- return { kind: 'call', line, callee, args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
210
+ const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
211
+ return { kind: 'call', line, callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
177
212
  }
178
213
  return null;
179
214
  }
@@ -21,8 +21,21 @@
21
21
  // failure (heredocs, multi-line strings can confuse the regex parser).
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
+ import { callSitesFromCfg } from './call-sites.js';
25
+ import { matchBalancedCall } from './balanced-call.js';
24
26
 
25
- const DEF_RE = /(?:^|\n)\s*def\s+(?:self\.)?(\w+[?!=]?)\s*(?:\(([^)]*)\))?/g;
27
+ // `[ \t]*` before the optional parameter list, NOT `\s*`.
28
+ //
29
+ // `\s*` crosses newlines, so for `def show\n c = params[:c]` the match ran to
30
+ // the next line's indentation. `parseRubyFile` then computes the body start as
31
+ // `indexOf('\n', m.index + m[0].length)`, which landed on the newline at the END
32
+ // of the first statement — so the body was sliced from after it and statement 1
33
+ // of EVERY Ruby method was silently discarded (a single-statement body became
34
+ // empty). In a Rails controller that first statement is almost always the
35
+ // `params` read, i.e. the taint source, which is why `bench/layer-recall`
36
+ // measured Ruby at 0/20 IR-TAINT recall while all 20 corpus entries passed on
37
+ // the regex layer. A parameter list on the same line still matches.
38
+ const DEF_RE = /(?:^|\n)[ \t]*def\s+(?:self\.)?(\w+[?!=]?)[ \t]*(?:\(([^)]*)\))?/g;
26
39
 
27
40
  function _extractRubyBody(src, defEnd) {
28
41
  let depth = 1;
@@ -110,10 +123,15 @@ function _lowerExpr(text) {
110
123
  if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
111
124
  // Symbol
112
125
  if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
113
- // Call: obj.method(args) or method(args)
114
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
126
+ // Call: obj.method(args) or method(args). matchBalancedCall finds the
127
+ // paren that actually balances the FIRST '(' — not the greedy-to-end-of-
128
+ // string match the old `/\((.*)\)\s*$/` used, which corrupted the
129
+ // argument text for a chained call (`sanitize(x).strip` produced
130
+ // args="x).strip", which then fell through to {kind:'unknown'} and
131
+ // silently dropped x).
132
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
115
133
  if (callMatch) {
116
- return { kind: 'call', callee: callMatch[1], args: _splitTopLevelCommas(callMatch[2]).map(_lowerExpr) };
134
+ return { kind: 'call', callee: callMatch.callee, args: _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr) };
117
135
  }
118
136
  // Method call without parens is very common in Ruby but hard to detect
119
137
  // reliably with regex. We handle the explicit-paren form above.
@@ -179,9 +197,9 @@ function _lowerStmt(stmt, line) {
179
197
  return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
180
198
  }
181
199
  // Statement-form call with parens
182
- const call = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
200
+ const call = matchBalancedCall(s, /^([\w.]+)/);
183
201
  if (call) {
184
- return { kind: 'call', line, callee: call[1], args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
202
+ return { kind: 'call', line, callee: call.callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
185
203
  }
186
204
  // Statement-form call without parens (common Ruby idiom): redirect_to expr
187
205
  const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
@@ -298,10 +316,22 @@ export function parseRubyFile(file, code) {
298
316
  const exit = _addNode(nodes, { kind: 'exit', line: startLine });
299
317
  const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
300
318
  _linkNodes(nodes, tail, exit);
319
+ const cfg = { entry, exit, nodes };
301
320
  functions.push({
302
321
  qid: _qid(file, name, startLine, extracted.body),
303
322
  name, line: startLine, params, file,
304
- cfg: { entry, exit, nodes },
323
+ cfg,
324
+ // Ruby never emitted `fn.calls` at all (ir/CLAUDE.md documents this
325
+ // as a known gap) — tabulation.js, dataflow/index.js and
326
+ // callgraph.js all read it to build call edges, so an absent array
327
+ // is indistinguishable from "calls nothing," disabling ALL Ruby
328
+ // interprocedural taint. call-sites.js's callSitesFromCfg is the
329
+ // same language-agnostic CFG walk parser-py-cst.js already uses for
330
+ // exactly this; Ruby's node shapes ('call' with callee/args,
331
+ // 'assign' with source, 'return'/'throw' with value, 'if' with
332
+ // cond) match its documented contract already, so no new lowering
333
+ // logic is needed here — only wiring the call.
334
+ calls: callSitesFromCfg(cfg),
305
335
  });
306
336
  DEF_RE.lastIndex = extracted.end;
307
337
  }
package/src/ir/ssa.js CHANGED
@@ -43,7 +43,12 @@ export function isSSAEnabled() {
43
43
  *
44
44
  * Returns Map<nodeId, Set<nodeId>> — dom[n] = set of nodes that dominate n.
45
45
  */
46
- function computeDominators(cfg) {
46
+ // Exported (Stage 6 correctness audit) so dataflow/implicit-flow.js can
47
+ // reuse this tested algorithm to correctly scope "is node N genuinely
48
+ // inside branch B" (N is inside iff B dominates N — every path to N passes
49
+ // through B) instead of a path-dependent DFS depth counter that had no way
50
+ // to detect a branch's join point.
51
+ export function computeDominators(cfg) {
47
52
  const nodes = Object.keys(cfg.nodes || {});
48
53
  const entry = cfg.entry;
49
54
  const dom = new Map();
@@ -27,6 +27,7 @@ import * as fs from 'node:fs';
27
27
  import * as path from 'node:path';
28
28
  import { summarizeForBadge } from './badge.js';
29
29
 
30
+ import { statePath } from './posture/state-dir.js';
30
31
  // Grade thresholds. Critical findings dominate; high/medium contribute
31
32
  // secondarily. These numbers are heuristic — calibrate against the
32
33
  // public leaderboard corpus once data lands.
@@ -90,7 +91,7 @@ function _deltaTrend(history) {
90
91
  */
91
92
  export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic-security.dev/badge' } = {}) {
92
93
  if (!repo) throw new Error('leaderboardRowFor: repo slug is required');
93
- const lastScanPath = path.join(scanRoot || '.', '.agentic-security', 'last-scan.json');
94
+ const lastScanPath = statePath(scanRoot || '.', 'last-scan.json');
94
95
  let scan = null;
95
96
  try { scan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8')); } catch {}
96
97
  const summary = summarizeForBadge(scan);
@@ -98,7 +99,7 @@ export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic
98
99
  const topCwe = _topCwe(scan);
99
100
 
100
101
  // Optional scan history for the trend signal.
101
- const historyPath = path.join(scanRoot || '.', '.agentic-security', 'scan-history.jsonl');
102
+ const historyPath = statePath(scanRoot || '.', 'scan-history.jsonl');
102
103
  let history = [];
103
104
  if (fs.existsSync(historyPath)) {
104
105
  try {
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
  import { validateOne } from './index.js';
26
26
 
27
+ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
27
28
  // Build a deterministic test finding from the project's last-scan.json,
28
29
  // or accept a hand-crafted one. Returns a clone safe to mutate per trial.
29
30
  export function makeTrialFinding(template) {
@@ -60,9 +61,12 @@ export async function measureConsistency({
60
61
  // If useCache is false, we want each trial to bypass the cache; we
61
62
  // simulate by mutating the finding's file in a way the cache key
62
63
  // hashes over. Simpler: clear the per-scanRoot cache before each trial.
63
- if (!useCache && scanRoot) {
64
+ // `stateWritesEnabled()` gates the DELETE as well as any write: removing
65
+ // files from the scanned tree is a mutation like any other, and under a
66
+ // read-only scan there is no cache to clear anyway. (PRD M1)
67
+ if (!useCache && scanRoot && stateWritesEnabled()) {
64
68
  try {
65
- const cacheDir = path.join(scanRoot, '.agentic-security', 'llm-cache');
69
+ const cacheDir = statePath(scanRoot, 'llm-cache');
66
70
  if (fs.existsSync(cacheDir)) {
67
71
  for (const e of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, e));
68
72
  }
@@ -64,7 +64,7 @@
64
64
  import * as fs from 'node:fs';
65
65
  import * as path from 'node:path';
66
66
  import * as crypto from 'node:crypto';
67
- import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
67
+ import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
68
68
  import { redactSecrets } from './redact.js';
69
69
  import { signLastScan } from '../posture/integrity.js';
70
70
 
@@ -84,7 +84,6 @@ const MAX_OUTPUT_TOKENS = 512;
84
84
  let _localPresetRefusal = null;
85
85
 
86
86
  export const PROMPT_VERSION = 'v2.0-hardened';
87
- const CACHE_DIR = '.agentic-security/llm-cache';
88
87
 
89
88
  // System preamble — embeds a per-request challenge token the model MUST
90
89
  // echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
package/src/lsp/server.js CHANGED
@@ -18,6 +18,8 @@ import * as path from 'node:path';
18
18
  import * as readline from 'node:readline';
19
19
  import { runScan } from '../runScan.js';
20
20
  import { resetCustomRulesBudget } from '../posture/custom-rules.js';
21
+ import { redactFinding } from '../mcp/redact.js';
22
+ import { _remediationOf } from '../report/index.js';
21
23
 
22
24
  const PROTOCOL_VERSION = '3.17';
23
25
  const SERVER_NAME = 'agentic-security-lsp';
@@ -52,6 +54,13 @@ function sevToLsp(sev) {
52
54
 
53
55
  function findingToDiagnostic(f) {
54
56
  const line = Math.max(0, (f.line || 1) - 1);
57
+ // Stage 6 correctness audit: this read f.remediation directly, but raw
58
+ // scan.findings entries (what this consumes, pre-normalizeFindings) come
59
+ // from two conventions — most posture/*.js and newer sast/*.js modules
60
+ // set `remediation`, while ~127 of engine.js's own detectors set a `fix`
61
+ // STRING field instead. _remediationOf carries the same precedence
62
+ // report/index.js already established for this exact split (CMP-3).
63
+ const remediation = _remediationOf(f);
55
64
  return {
56
65
  range: {
57
66
  start: { line, character: 0 },
@@ -60,7 +69,7 @@ function findingToDiagnostic(f) {
60
69
  severity: sevToLsp(f.severity),
61
70
  source: 'agentic-security',
62
71
  code: f.cwe || f.family || 'finding',
63
- message: `${f.vuln || 'Security finding'}${f.remediation ? '\n\n' + (typeof f.remediation === 'string' ? f.remediation : '') : ''}`.slice(0, 2000),
72
+ message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
64
73
  tags: [],
65
74
  };
66
75
  }
@@ -142,7 +151,20 @@ async function scanFile(uri) {
142
151
  // and eventually start skipping custom rules.
143
152
  resetCustomRulesBudget(_rootDir);
144
153
  const { scan } = await runScan(_rootDir, { fileContents, depFileContents });
145
- const findings = (scan.findings || []).filter(f => f.file === rel);
154
+ // Stage 6 correctness audit: this only ever read scan.findings (the SAST
155
+ // channel). scan.secrets and scan.logicVulns are separate arrays on the
156
+ // raw runScan() result — normalizeFindings is what merges all four
157
+ // channels, and that hasn't run here — so a saved file with a hardcoded
158
+ // credential got a clean problem pane, no diagnostic at all. Unlike the
159
+ // MCP surface, this server never applied redactFinding either (nothing
160
+ // here imported mcp/redact.js), which would have been a landmine the
161
+ // moment secrets/logicVulns were added without it: those channels are
162
+ // exactly where raw secret material shows up in `snippet`. Both fixed
163
+ // together — merge the channels AND redact — so the fix for one gap
164
+ // doesn't open the other.
165
+ const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
166
+ .filter(f => f.file === rel)
167
+ .map(f => redactFinding(f));
146
168
  await publishDiagnostics(uri, findings);
147
169
  } catch (e) {
148
170
  process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
@@ -273,3 +295,7 @@ export function startLspServer() {
273
295
  if (import.meta.url === `file://${process.argv[1]}`) {
274
296
  startLspServer();
275
297
  }
298
+
299
+ function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
300
+
301
+ export const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
package/src/mcp/CLAUDE.md CHANGED
@@ -12,14 +12,21 @@ 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; no writes |
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
16
  | `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
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 |
20
20
  | `read_agents_memory` | ✓ | tail of `.agentic-security/AGENTS.md` |
21
+ | `synthesize_sca_upgrade` | ✓ | runs an ecosystem dry-run (`npm install --dry-run` etc.); returns the upgrade plan; no writes |
22
+ | `apply_sca_upgrade` | ✗ | backs up manifests, runs the package manager, runs the project test command, restores manifests on test failure |
23
+ | `query_triage_memory` | ✓ | reads past triage decisions (wont-fix/false-positive) by natural-language query |
24
+ | `query_findings_memory` | ✓ | reads accumulated scan memory (findings + triage history + AGENTS.md) by natural-language query |
25
+ | `query_cache_telemetry` | ✓ | reads prompt-cache economics from the current session transcript; no network |
21
26
 
22
- `apply_fix` is the only write tool. It requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list.
27
+ **17 tools, not 12** this table previously stopped at 12 and the count quoted elsewhere (root `CLAUDE.md`, the non-Claude plugin manifests) said "Six." Re-derive with `grep -c "name: '" scanner/src/mcp/tools.js` rather than trusting a hardcoded number here again.
28
+
29
+ **Two write tools, not one.** `apply_fix` and `apply_sca_upgrade` both write; `verify_fix` also writes (see its row above) though not to the target project's own files. `apply_fix` additionally requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list; `apply_sca_upgrade` requires `confirm:true` and gates on its own test-restore cycle.
23
30
 
24
31
  ## Hardening posture (OWASP MCP Top 10)
25
32
 
package/src/mcp/audit.js CHANGED
@@ -27,6 +27,7 @@ import * as path from 'node:path';
27
27
  import * as crypto from 'node:crypto';
28
28
  import { redactArgsBlob } from './redact.js';
29
29
 
30
+ import { stateDir } from '../posture/state-dir.js';
30
31
  const MAX_ARG_BYTES = 1024;
31
32
  const GENESIS = 'GENESIS';
32
33
  const REMOTE_TIMEOUT_MS = 1500;
@@ -87,7 +88,7 @@ export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
87
88
  let hasMarker = false;
88
89
  for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
89
90
  if (!hasMarker) return;
90
- const dir = path.join(sessionRoot, '.agentic-security');
91
+ const dir = stateDir(sessionRoot);
91
92
  fs.mkdirSync(dir, { recursive: true });
92
93
  const logFile = path.join(dir, 'mcp-audit.log');
93
94
  const entry = {