@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.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 (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -65,7 +65,7 @@ import * as fs from 'node:fs';
65
65
  import * as path from 'node:path';
66
66
  import * as crypto from 'node:crypto';
67
67
  import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
68
- import { redactSecrets } from './redact.js';
68
+ import { redactPayload } from '../egress/redact.js';
69
69
  import { signLastScan } from '../posture/integrity.js';
70
70
 
71
71
  // Bump on every prompt change so the cache invalidates. Exported as a
@@ -74,6 +74,9 @@ import { signLastScan } from '../posture/integrity.js';
74
74
  import { createCostLedger, parseCapUsd, renderCostCeiling } from './cost-ceiling.js';
75
75
  import { localEndpointConfig } from './local-endpoint.js';
76
76
  import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
77
+ import { evaluateEgress } from '../egress/policy.js';
78
+ import { recordEgressCall, payloadMetrics } from '../egress/audit.js';
79
+ import { MODEL_STATUS, summarizeModelStatus } from './model-status.js';
77
80
 
78
81
  // The output cap we request. Shared with the cost estimate so the ceiling
79
82
  // charges exactly what we permit the model to produce.
@@ -295,7 +298,7 @@ export function sanitizeReasoning(s) {
295
298
  .slice(0, 280);
296
299
  }
297
300
 
298
- function renderPrompt(finding, fileContents, challenge, nonce) {
301
+ function renderPrompt(finding, fileContents, challenge, nonce, scanRoot) {
299
302
  const code = fileContents?.[finding.file];
300
303
  let context = '';
301
304
  if (code && finding.line) {
@@ -315,12 +318,14 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
315
318
  let sterileSnippet = String(finding.snippet || '')
316
319
  .replace(/[\r\n]+/g, ' ')
317
320
  .slice(0, 400);
318
- // R10 — redact likely live credentials (API keys, tokens, private keys,
319
- // connection-string passwords, ...) out of BOTH excerpts before anything
320
- // leaves the machine. This is the last choke point before the prompt is
321
- // assembled everything downstream sees only redacted text.
322
- sterileContext = redactSecrets(sterileContext).text;
323
- sterileSnippet = redactSecrets(sterileSnippet).text;
321
+ // R10 / FR-603 — redact secrets (API keys, tokens, private keys,
322
+ // connection-string passwords), PII/PHI/PCI/FIN-shaped fields, operator-
323
+ // configured customer-data patterns, and whole-file content from an
324
+ // operator-configured proprietary path out of BOTH excerpts before
325
+ // anything leaves the machine. This is the last choke point before the
326
+ // prompt is assembled — everything downstream sees only redacted text.
327
+ sterileContext = redactPayload({ text: sterileContext, filePath: finding.file, scanRoot }).text;
328
+ sterileSnippet = redactPayload({ text: sterileSnippet, filePath: finding.file, scanRoot }).text;
324
329
  return PROMPT_TEMPLATE
325
330
  .replace(/\{\{nonce\}\}/g, nonce)
326
331
  .replace(/\{\{challenge\}\}/g, challenge)
@@ -440,8 +445,35 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
440
445
  if (!cfg) {
441
446
  finding.validator_verdict = 'unvalidated';
442
447
  finding.unvalidated = true;
448
+ // FR-606: distinguish "the local preset refused a configured but
449
+ // non-loopback endpoint" (a policy decision) from "nothing was
450
+ // configured at all" (the tier is simply off) — endpointConfig()
451
+ // leaves _localPresetRefusal set only for the former.
452
+ finding.llmValidationStatus = _localPresetRefusal ? MODEL_STATUS.POLICY_BLOCKED : MODEL_STATUS.DISABLED;
443
453
  return { verdict: 'unvalidated' };
444
454
  }
455
+ // FR-601: egress policy evaluated before anything about this finding is
456
+ // rendered into a prompt (renderPrompt runs later, at line ~486) and before
457
+ // callEndpoint's fetch. A denial short-circuits here — no prompt is ever
458
+ // built for a denied endpoint, and the finding gets a distinct, honest
459
+ // error tag rather than being silently lumped in with "no answer received".
460
+ // FR-602: role/model are already resolved above (resolveProvider's own
461
+ // 'validate' role, cfg.model) — threading them through here is what
462
+ // makes the model/role constraint dimensions genuinely enforceable for a
463
+ // real caller, not just a mechanism nothing exercises.
464
+ const egressDecision = evaluateEgress({ scanRoot, purpose: 'llm-validator', endpoint: cfg.endpoint, role: 'validate', model: cfg.model });
465
+ if (!egressDecision.allowed) {
466
+ finding.validator_verdict = 'unvalidated';
467
+ finding.unvalidated = true;
468
+ finding._validatorError = 'egress-policy-denied';
469
+ finding._egressDecision = egressDecision;
470
+ finding.llmValidationStatus = MODEL_STATUS.POLICY_BLOCKED;
471
+ // FR-604: a denied call still gets an audit entry — purpose/provider/
472
+ // model/policy/outcome are all known without ever building a prompt,
473
+ // so byte/token/hash fields are simply absent (nothing was constructed).
474
+ recordEgressCall({ scanRoot, decision: egressDecision, ctx: { model: cfg.model, region: null } });
475
+ return { verdict: 'unvalidated', error: 'egress-policy-denied', egressDecision };
476
+ }
445
477
  // Pre-flight: refuse to validate location-less findings. Without a precise
446
478
  // file:line, the response cross-check degenerates and the validator can be
447
479
  // spoofed by trivially-true echoes.
@@ -479,11 +511,20 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
479
511
  // against any future write-path regression that might cache un-sanitized text).
480
512
  finding.validator_reasoning = sanitizeReasoning(cached.reasoning);
481
513
  finding._validatorCache = 'hit';
514
+ // FR-606: a cache hit means the model DID answer at some point — not
515
+ // re-asking is an optimization, not a different outcome.
516
+ finding.llmValidationStatus = MODEL_STATUS.COMPLETED;
482
517
  return cached;
483
518
  }
484
519
  const challenge = crypto.randomBytes(8).toString('hex');
485
520
  const nonce = crypto.randomBytes(8).toString('hex');
486
- const prompt = renderPrompt(finding, fileContents, challenge, nonce);
521
+ const prompt = renderPrompt(finding, fileContents, challenge, nonce, scanRoot);
522
+ // FR-604: audit the allowed call now that the final (redacted) outbound
523
+ // payload is known — byte/token counts and a content hash, never the
524
+ // payload text itself. Recorded even if the call below fails or is
525
+ // blocked by the cost ceiling: the egress DECISION was 'allow' and a
526
+ // real payload was built, which is the fact this entry attests to.
527
+ recordEgressCall({ scanRoot, decision: egressDecision, ctx: { model: cfg.model, region: null }, metrics: payloadMetrics(prompt) });
487
528
 
488
529
  // R12 — the hard ceiling. Checked BEFORE the call, against a conservative
489
530
  // estimate: input from the prompt we are about to send, output at the full
@@ -503,6 +544,9 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
503
544
  finding.validator_verdict = 'unvalidated';
504
545
  finding.unvalidated = true;
505
546
  finding.validator_skipped_reason = afford.reason;
547
+ // FR-606: a cost ceiling is an operator-configured policy denying the
548
+ // call, the same bucket as an egress denial — not a network failure.
549
+ finding.llmValidationStatus = MODEL_STATUS.POLICY_BLOCKED;
506
550
  return { verdict: 'unvalidated', error: 'cost-ceiling' };
507
551
  }
508
552
  }
@@ -522,6 +566,11 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
522
566
  finding.validator_verdict = 'unvalidated';
523
567
  finding.unvalidated = true;
524
568
  finding._validatorError = resp.error;
569
+ // FR-606: the call was attempted and failed before any usable response
570
+ // arrived (non-2xx HTTP, or a fetch/network exception in callEndpoint)
571
+ // — the endpoint is unavailable right now, distinct from a policy
572
+ // refusal or a malformed reply.
573
+ finding.llmValidationStatus = MODEL_STATUS.UNAVAILABLE;
525
574
  return { verdict: 'unvalidated', error: resp.error };
526
575
  }
527
576
  const obj = parseLastJsonObject(resp.text);
@@ -533,6 +582,11 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
533
582
  finding._validatorError = `verify-failed:${v.reason}`;
534
583
  finding.llm_confidence = 0.5;
535
584
  finding.validator_reasoning = sanitizeReasoning(`escalate (verify-failed:${v.reason})`);
585
+ // FR-606: a response DID come back, but its content could not be
586
+ // parsed/verified into a usable answer — the model answered something,
587
+ // just not something usable. Distinct from UNAVAILABLE (no response at
588
+ // all) and from a real verdict.
589
+ finding.llmValidationStatus = MODEL_STATUS.MALFORMED;
536
590
  return { verdict: 'escalate', error: v.reason };
537
591
  }
538
592
  const parsed = v.parsed;
@@ -541,6 +595,7 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
541
595
  finding.llm_confidence = parsed.confidence;
542
596
  finding.validator_reasoning = parsed.reasoning;
543
597
  finding._validatorCache = 'miss';
598
+ finding.llmValidationStatus = MODEL_STATUS.COMPLETED;
544
599
  return parsed;
545
600
  }
546
601
 
@@ -563,12 +618,19 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
563
618
  const optOut = process.env.AGENTIC_SECURITY_LLM_VALIDATE === '0';
564
619
  const enabled = !!cfg && !optOut;
565
620
  if (!enabled) {
621
+ // FR-606: same distinction as validateOne's own !cfg branch — a
622
+ // configured-but-refused endpoint (local preset, non-loopback) is
623
+ // policy-blocked; nothing configured, OR an explicit operator opt-out
624
+ // (AGENTIC_SECURITY_LLM_VALIDATE=0), is the tier simply being off.
625
+ const status = _localPresetRefusal ? MODEL_STATUS.POLICY_BLOCKED : MODEL_STATUS.DISABLED;
566
626
  for (const f of findings) {
567
627
  f.validator_verdict = 'unvalidated';
568
628
  f.unvalidated = true;
569
629
  if (_localPresetRefusal) f.validator_skipped_reason = _localPresetRefusal;
630
+ f.llmValidationStatus = status;
570
631
  }
571
632
  if (_localPresetRefusal) findings.localPathRefusal = _localPresetRefusal;
633
+ findings.llmValidatorStatus = summarizeModelStatus(findings);
572
634
  return findings;
573
635
  }
574
636
  const candidates = findings.filter(f =>
@@ -593,7 +655,11 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
593
655
  f.validator_verdict = 'unvalidated';
594
656
  f.unvalidated = true;
595
657
  f.validator_skipped_reason = e.message;
658
+ // FR-606: a malformed operator config is a policy problem, same
659
+ // bucket as a valid-but-exceeded cost ceiling — not a network failure.
660
+ f.llmValidationStatus = MODEL_STATUS.POLICY_BLOCKED;
596
661
  }
662
+ findings.llmValidatorStatus = summarizeModelStatus(findings);
597
663
  return findings;
598
664
  }
599
665
 
@@ -606,6 +672,11 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
606
672
  // FAIL-CLOSED on exception too.
607
673
  candidates[idx].validator_verdict = 'escalate';
608
674
  candidates[idx]._validatorError = e.message;
675
+ // FR-606: an unexpected exception during validation (not a
676
+ // network-layer failure — those are already caught inside
677
+ // callEndpoint/validateOne) still means no usable answer was
678
+ // produced; closest of the five to what actually happened.
679
+ candidates[idx].llmValidationStatus = MODEL_STATUS.UNAVAILABLE;
609
680
  }
610
681
  }
611
682
  }
@@ -635,7 +706,13 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
635
706
  if (f.validator_verdict) continue;
636
707
  f.validator_verdict = 'unvalidated';
637
708
  f.unvalidated = true;
709
+ // FR-606 defensive fallback: every real branch above already sets this;
710
+ // reaching here means a candidate was filtered out (not a model-tier
711
+ // pass at all) — DO NOT stamp llmValidationStatus for those, the same
712
+ // "not applicable" convention validateOne itself uses for SCA locators
713
+ // and no-precise-location findings.
638
714
  }
715
+ findings.llmValidatorStatus = summarizeModelStatus(findings);
639
716
  return findings;
640
717
  }
641
718
 
@@ -0,0 +1,66 @@
1
+ // FR-606 (assurance-hardening PRD): the 5-state model-status vocabulary
2
+ // reports must distinguish, so an LLM-backed feature never converts "the
3
+ // model didn't answer" into an undifferentiated clean result.
4
+ //
5
+ // Before this module, every degrade path in llm-validator/index.js set the
6
+ // SAME finding.validator_verdict ('unvalidated'), with the actual reason
7
+ // scattered across an inconsistently-populated free-text field
8
+ // (_validatorError: 'egress-policy-denied' | 'cost-ceiling' | 'HTTP 500' |
9
+ // an arbitrary exception message | ...unset entirely for the "nothing
10
+ // configured" case). A reader — human or downstream tool — had no reliable
11
+ // way to answer "did the model tier even run, and if not, why" from that
12
+ // shape alone.
13
+ //
14
+ // Attached to each finding llm-validator touches as
15
+ // finding.llmValidationStatus, carrying EXACTLY one of the five values
16
+ // below (never a sixth ad-hoc string), and aggregated into a scan-level
17
+ // summary via summarizeModelStatus so a reader gets the headline without
18
+ // inspecting every finding.
19
+ //
20
+ // Deliberately NOT stamped on findings the validator skips for reasons
21
+ // that have nothing to do with MODEL availability (an SCA locator with no
22
+ // line number, a finding with no precise file:line at all) — those already
23
+ // have their own honest verdict ('not-applicable') and status; forcing
24
+ // them into this taxonomy would misrepresent a finding-suitability
25
+ // question as a model-availability one.
26
+
27
+ export const MODEL_STATUS = Object.freeze({
28
+ // No endpoint resolved at all — no API key, no BYO endpoint, nothing
29
+ // configured. The tier is off, not broken; nothing was attempted.
30
+ DISABLED: 'model-disabled',
31
+ // Something WAS configured, but a policy refused to use it before any
32
+ // network call was attempted — the egress policy's mode:deny/local-only/
33
+ // provider allow-deny lists, the local preset's own loopback-only
34
+ // refusal, or an internal cost-ceiling cap.
35
+ POLICY_BLOCKED: 'policy-blocked',
36
+ // The call was attempted and failed before a usable response arrived —
37
+ // a network error, a timeout, or a non-2xx HTTP status.
38
+ UNAVAILABLE: 'unavailable',
39
+ // A response DID come back, but its content could not be parsed into a
40
+ // usable answer, or failed the challenge/nonce cross-check — the model
41
+ // answered something, just not something usable.
42
+ MALFORMED: 'malformed',
43
+ // A real, validated verdict was produced (including a cache hit of one
44
+ // produced earlier — the model answered at some point; not re-asking is
45
+ // an optimization, not a different outcome).
46
+ COMPLETED: 'completed',
47
+ });
48
+
49
+ const ALL_STATUSES = Object.values(MODEL_STATUS);
50
+
51
+ /**
52
+ * Aggregate the per-finding llmValidationStatus values into scan-level
53
+ * counts. Findings with no llmValidationStatus at all (not applicable to
54
+ * model availability — see the module header) are counted separately and
55
+ * never silently folded into one of the five buckets.
56
+ */
57
+ export function summarizeModelStatus(findings) {
58
+ const counts = Object.fromEntries(ALL_STATUSES.map(s => [s, 0]));
59
+ let notApplicable = 0;
60
+ for (const f of findings || []) {
61
+ const s = f?.llmValidationStatus;
62
+ if (s && Object.prototype.hasOwnProperty.call(counts, s)) counts[s]++;
63
+ else notApplicable++;
64
+ }
65
+ return { counts, notApplicable, total: (findings || []).length };
66
+ }
package/src/mcp/tools.js CHANGED
@@ -16,12 +16,16 @@ import * as fs from 'node:fs';
16
16
  import * as fsp from 'node:fs/promises';
17
17
  import * as path from 'node:path';
18
18
  import * as crypto from 'node:crypto';
19
- import { applyFix as applyFixHistory, fixAcceptanceRate } from '../posture/fix-history.js';
19
+ import { applyFix as applyFixHistory, fixAcceptanceRate, revertEntryById as revertFixEntry } from '../posture/fix-history.js';
20
+ import { applyVerifiedFix } from '../fix/apply-fix-service.js';
21
+ import { classifyFixMaterialRisk } from '../posture/material-change.js';
22
+ import { loadApproverRegistry, verifyApprover, requiredRolesFor, checkSeparationOfDuties } from '../fix/approver-registry.js';
20
23
  import { synthesizeDeterministicPatch } from '../posture/deterministic-fix.js';
21
24
  import { verifyLastScan } from '../posture/integrity.js';
25
+ import { withStateWritesDisabled } from '../posture/state-dir.js';
22
26
  import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
23
27
  import { redactString, redactFinding } from './redact.js';
24
- import { _remediationOf } from '../report/index.js';
28
+ import { _remediationOf, normalizeFindings } from '../report/index.js';
25
29
 
26
30
  // Lazy-loaded: these transitively pull in npm packages (@babel/core and
27
31
  // friends) that aren't available in the plugin-cache install path
@@ -387,26 +391,44 @@ export const scan_diff = {
387
391
  // / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
388
392
  // full-project deep scan).
389
393
  const runScan = await getRunScan();
390
- const result = await runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true });
394
+ // FR-704 (assurance-hardening PRD): this tool's own description promises
395
+ // "runs scan in memory" — without this, runFullScan's own state writers
396
+ // (dpia.md, ropa.md, privacy-framework.json, threat-model.json, and
397
+ // others) fire unconditionally on every call, silently mutating the
398
+ // user's real project on every pre-write self-correction scan. Confirmed
399
+ // by direct execution before this fix (11 state artifacts written by a
400
+ // single scan_diff-shaped call).
401
+ const result = await withStateWritesDisabled(() =>
402
+ runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
391
403
  const wantSet = new Set(Object.keys(fileContents));
392
404
  const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
393
405
  const min = sevRank[severity] ?? 0;
394
- // Stage 6 correctness audit: this only ever read result.scan.findings
395
- // (the SAST channel) — scan.secrets and scan.logicVulns are separate
396
- // arrays on the raw runScan() result (report/index.js's normalizeFindings
397
- // is what merges all four channels, and that merge hasn't run yet here).
398
- // A file containing a bare hardcoded credential reported findingCount: 0
399
- // through a tool whose own description promises "Use BEFORE writing a
400
- // Write/Edit to disk so the agent can self-correct". Also reused
401
- // _remediationOf so a fix-string detector (the majority of engine.js's
402
- // own, ~127 call sites) doesn't silently report an empty `description`
403
- // the way reading only `.remediation` did.
404
- const findings = [...(result.scan.findings || []), ...(result.scan.secrets || []), ...(result.scan.logicVulns || [])]
406
+ // Stage 6 correctness audit (historical): this used to only read
407
+ // result.scan.findings (the SAST channel) — scan.secrets and
408
+ // scan.logicVulns are separate arrays on the raw runScan() result, and a
409
+ // hand-rolled 3-channel concat here was a second, divergent copy of the
410
+ // merge report/index.js's normalizeFindings() already does (four
411
+ // channels, plus per-channel defaulting and remediation-string
412
+ // resolution the old concat re-implemented separately and could drift
413
+ // from). Assurance-hardening PRD FR-105 ("JSON, SARIF, HTML, CSV, JUnit,
414
+ // and MCP outputs derive from the same validated object"): route through
415
+ // the same canonical merge every other output format uses.
416
+ //
417
+ // This closes the field-mapping/dedup divergence, but does NOT make
418
+ // scan_diff surface SCA/supply-chain findings end to end: this handler
419
+ // never builds a `depFileContents` map (everything a caller passes in
420
+ // `files`, manifests included, lands in `fileContents`), and manifest-
421
+ // based supply-chain detection in engine.js reads only `depFileContents`
422
+ // — so `result.scan.supplyChain` is always empty for this tool today
423
+ // regardless of this fix. That is a separate, real limitation (scan_diff
424
+ // was designed for pre-write code self-correction, not manifest
425
+ // scanning), left as-is rather than silently claimed fixed here.
426
+ const findings = normalizeFindings(result.scan)
405
427
  .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
406
428
  .map(f => redactFinding({
407
429
  id: f.id, severity: f.severity, file: f.file, line: f.line,
408
- title: f.title || f.vuln, cwe: f.cwe,
409
- description: f.description, remediation: _remediationOf(f),
430
+ title: f.vuln, cwe: f.cwe,
431
+ description: f.description, remediation: f.remediation,
410
432
  }));
411
433
  // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
412
434
  // The agent gets a head+tail preview plus a path it can page through;
@@ -556,7 +578,7 @@ export const explain_finding = {
556
578
  // ─── apply_fix ───────────────────────────────────────────────────────────────
557
579
  export const apply_fix = {
558
580
  name: 'apply_fix',
559
- description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
581
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
560
582
  inputSchema: {
561
583
  type: 'object',
562
584
  additionalProperties: false,
@@ -597,6 +619,27 @@ export const apply_fix = {
597
619
  partialSanitization: { type: 'boolean' },
598
620
  },
599
621
  },
622
+ // FR-307/FR-1002: this schema had `additionalProperties: false`
623
+ // and never declared `approval` — the property apply-fix-
624
+ // service.js's high-impact-change gate has required since FR-307
625
+ // was built. A real MCP caller supplying fixMeta.approval was
626
+ // rejected by validate.js at the schema layer before the handler
627
+ // ever ran, silently making the approval gate (and FR-1002's
628
+ // identity check layered on it) unreachable from this tool's
629
+ // only real production entry point. See D-0024.
630
+ approval: {
631
+ type: 'object',
632
+ additionalProperties: false,
633
+ properties: {
634
+ approvedBy: { type: 'string', minLength: 1, maxLength: 200 },
635
+ reason: { type: 'string', minLength: 1, maxLength: 1000 },
636
+ },
637
+ },
638
+ // FR-1003: separation-of-duties. Self-reported the same way
639
+ // approvedBy is — this tool has no way to determine who actually
640
+ // wrote a patch, so `author` is a claim, checked against a
641
+ // configurable policy the same way `approval` is.
642
+ author: { type: 'string', minLength: 1, maxLength: 200 },
600
643
  },
601
644
  },
602
645
  },
@@ -671,20 +714,78 @@ export const apply_fix = {
671
714
  verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
672
715
  };
673
716
  }
717
+ // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
718
+ // applyFixHistory() directly and never called applyVerifiedFix() — so
719
+ // the high-impact-change approval gate (auth/authZ/crypto/PII/schema/
720
+ // infra-privilege/public-API) built for the OTHER apply_fix branch
721
+ // (stored fix.replacement) never ran here at all, for any input. Since
722
+ // this is the branch the tool's own description calls the one that
723
+ // covers "~100% of findings that ship only a template," that gap was
724
+ // the larger of the two found this cycle. Same before/after content
725
+ // shape `apply-fix-service.js` already uses — read-first-in-try/catch
726
+ // (D-0012), never existsSync-then-readFileSync.
727
+ const filesForMaterialClassification = {};
728
+ for (const [rel, v] of Object.entries(confinedAbs)) {
729
+ let before = '';
730
+ try { before = await fsp.readFile(v.abs, 'utf8'); } catch { /* new file — before stays '' */ }
731
+ filesForMaterialClassification[rel] = { before, after: v.content };
732
+ }
733
+ const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
674
734
  if (dry_run) {
675
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
735
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
736
+ }
737
+ if (materialClassification.highImpactCategories.length) {
738
+ const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
739
+ const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
740
+ typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
741
+ typeof approval.reason === 'string' && approval.reason.trim().length > 0);
742
+ if (!hasApprovalEvidence) {
743
+ return {
744
+ _meta: META, applied: false,
745
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
746
+ materialClassification,
747
+ };
748
+ }
749
+ const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
750
+ const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
751
+ const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
752
+ if (!identityCheck.verified) {
753
+ return {
754
+ _meta: META, applied: false,
755
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
756
+ materialClassification,
757
+ };
758
+ }
759
+ // FR-1003: separation-of-duties, same no-op-unless-configured gate
760
+ // as apply-fix-service.js's own copy — see approver-registry.js.
761
+ const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
762
+ if (!sodCheck.ok) {
763
+ return {
764
+ _meta: META, applied: false,
765
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
766
+ materialClassification,
767
+ };
768
+ }
676
769
  }
677
770
  const written = [];
678
771
  try {
679
772
  for (const [rel, v] of Object.entries(confinedAbs)) {
680
- const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
773
+ const fileExisted = fs.existsSync(v.abs);
774
+ const originalContent = fileExisted ? await fsp.readFile(v.abs, 'utf8') : '';
681
775
  const entry = await applyFixHistory({
682
- scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
776
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
683
777
  findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
684
778
  });
685
779
  written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
686
780
  }
687
781
  } catch (e) {
782
+ // FR-306: roll back every file THIS batch already wrote before the
783
+ // failure — applyFixHistory already restored the one file that just
784
+ // failed; this covers the rest, so a multi-file patch never leaves
785
+ // some files patched and others not.
786
+ for (const w of written) {
787
+ try { await revertFixEntry(ctx.sessionRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
788
+ }
688
789
  if (e && e.name === 'FixAttemptBudgetExceededError') {
689
790
  return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
690
791
  }
@@ -692,7 +793,7 @@ export const apply_fix = {
692
793
  }
693
794
  let acceptance = null;
694
795
  try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
695
- return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance };
796
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
696
797
  }
697
798
 
698
799
  if (typeof f.fix?.replacement !== 'string') {
@@ -729,41 +830,47 @@ export const apply_fix = {
729
830
  };
730
831
  }
731
832
 
732
- let entry;
733
- try {
734
- entry = await applyFixHistory({
735
- scanRoot: ctx.sessionRoot,
736
- file: f.file,
737
- originalContent,
738
- newContent: f.fix.replacement,
739
- findingId: f.id,
740
- stableId: f.stableId || null, // premortem 4R-8
741
- ruleId: f.ruleId || f.cwe || f.family || null,
742
- vuln: f.vuln || f.title || null,
743
- });
744
- } catch (e) {
745
- // Harness-engineering: step-budget refusal (post-derived). The
746
- // deterministic layer enforces at-most-N attempts per stableId. When
747
- // exceeded, surface it as a structured `budget-exceeded` outcome the
748
- // agent can recognize — not a generic error.
749
- if (e && e.name === 'FixAttemptBudgetExceededError') {
750
- return {
751
- _meta: META,
752
- applied: false,
753
- reason: `budget-exceeded: ${e.message}`,
754
- budgetExceeded: true,
755
- attempts: e.attempts,
756
- maxAttempts: e.max,
757
- key: e.key,
758
- };
833
+ // FR-301/A-08 (assurance-hardening PRD): this branch used to write
834
+ // f.fix.replacement straight to disk with NO fresh verification — no
835
+ // rescan, no lint, nothing confirming the stored replacement actually
836
+ // closes the finding it claims to fix. The caller-patch branch above
837
+ // already required this; there is no reason a STORED fix should be
838
+ // trusted more than a caller-supplied one just because it shipped with
839
+ // the finding. Routed through the same applyVerifiedFix() service the
840
+ // CLI's `fix --apply` now also uses (src/fix/apply-fix-service.js) —
841
+ // confinement/reserved-path are re-checked there too (harmless
842
+ // redundancy with the dry_run preview above, kept for that preview's
843
+ // size-diff shape) but the load-bearing addition is the verification
844
+ // gate before the write.
845
+ if (!f.stableId) {
846
+ return { _meta: META, applied: false, reason: 'finding has no stableId — cannot verify a stored fix against it' };
847
+ }
848
+ const result = await applyVerifiedFix({
849
+ scanRoot: ctx.sessionRoot,
850
+ finding: f,
851
+ files: { [f.file]: f.fix.replacement },
852
+ fixMeta,
853
+ });
854
+ if (!result.ok) {
855
+ if (result.budgetExceeded) {
856
+ return { _meta: META, applied: false, reason: result.reason, budgetExceeded: true, attempts: result.attempts, maxAttempts: result.maxAttempts, key: result.key };
759
857
  }
760
- throw e;
858
+ return { _meta: META, applied: false, reason: result.reason, verify: result.verify || null };
761
859
  }
762
860
  // R25 (PRD §5): surface the running auto-fix acceptance rate after each
763
861
  // applied fix, so the closed loop reports its own success metric.
764
862
  let acceptance = null;
765
863
  try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* metric is best-effort */ }
766
- return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance };
864
+ const entry = result.written[0];
865
+ return {
866
+ // FR-305: verifiedFull distinguishes "every required leg (lint, tests)
867
+ // genuinely ran and passed" from "passed, but a required leg was
868
+ // skipped or unavailable" — verified:true alone conflates them.
869
+ _meta: META, applied: true, verified: true, verifiedFull: result.verifiedFull,
870
+ historyId: entry.historyId, file: entry.file, backupPath: entry.backupPath,
871
+ integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance,
872
+ verify: result.verify,
873
+ };
767
874
  },
768
875
  };
769
876
 
@@ -0,0 +1,93 @@
1
+ // Real, preemptive deadline enforcement via worker_threads (assurance-
2
+ // hardening PRD FR-202, D-0046 phase 1).
3
+ //
4
+ // FR-201's runDetector (detector-runner.js) catches a THROWN exception —
5
+ // it does nothing for a detector that hangs (infinite loop, catastrophic
6
+ // regex backtracking), because JavaScript is single-threaded: nothing
7
+ // running in the same event loop as a synchronous hang can preempt it. The
8
+ // existing _perFileTimeoutMs (engine.js) and AGENTIC_SECURITY_DEEP_TIMEOUT_MS
9
+ // (dataflow/engine.js) are both COOPERATIVE — they measure elapsed time
10
+ // after work already finished, or poll the clock between iterations of an
11
+ // already-bounded loop. Neither can stop a genuinely hung synchronous call.
12
+ //
13
+ // runWithDeadline is the one piece that can: it races a worker_thread
14
+ // against a real timer and calls worker.terminate() — an OS-level
15
+ // preemptive kill — if the timer wins. This is phase 1 of D-0046's 4-phase
16
+ // plan: a proven, isolated primitive, not yet wired into engine.js's real
17
+ // scan loop (phase 3). See D-0046 for the full plan and why per-file
18
+ // (not per-detector) granularity is the eventual wiring target.
19
+
20
+ import { Worker } from 'node:worker_threads';
21
+ import * as path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+ const DEFAULT_WORKER_SCRIPT = path.join(HERE, 'analyzer-worker.js');
26
+
27
+ // PRD FR-202: "the configured deadline plus a documented grace period."
28
+ // The grace period exists so a worker that finishes essentially on time
29
+ // (message already in flight) isn't killed by scheduler jitter.
30
+ export const DEFAULT_GRACE_MS = 500;
31
+
32
+ /**
33
+ * Run `exportName` from `modulePath`, called with `args`, inside a worker
34
+ * thread, with a real preemptive deadline.
35
+ *
36
+ * @param {object} spec
37
+ * @param {string} spec.modulePath - absolute path to the module to import inside the worker.
38
+ * @param {string} spec.exportName - the named export to call.
39
+ * @param {Array} [spec.args] - plain, structured-clone-safe arguments.
40
+ * @param {object} [opts]
41
+ * @param {number} opts.timeoutMs - required. The configured deadline.
42
+ * @param {number} [opts.graceMs] - documented grace period beyond timeoutMs before terminating. Default DEFAULT_GRACE_MS.
43
+ * @param {string} [opts.workerScript] - override for testing.
44
+ * @returns {Promise<{ok:true, result:*} | {ok:false, timedOut?:true, error?:string}>}
45
+ */
46
+ export function runWithDeadline(spec, opts = {}) {
47
+ const { modulePath, exportName, args = [] } = spec || {};
48
+ const timeoutMs = opts.timeoutMs;
49
+ const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
50
+ const workerScript = opts.workerScript || DEFAULT_WORKER_SCRIPT;
51
+
52
+ if (!modulePath || !exportName) {
53
+ return Promise.resolve({ ok: false, error: 'runWithDeadline requires spec.modulePath and spec.exportName' });
54
+ }
55
+ if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) {
56
+ return Promise.resolve({ ok: false, error: 'runWithDeadline requires opts.timeoutMs > 0' });
57
+ }
58
+
59
+ return new Promise((resolve) => {
60
+ let settled = false;
61
+ const worker = new Worker(workerScript, { workerData: { modulePath, exportName, args } });
62
+
63
+ const finish = (value) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ clearTimeout(killTimer);
67
+ worker.terminate().catch(() => {});
68
+ resolve(value);
69
+ };
70
+
71
+ // The one preemptive mechanism: a real OS-level Worker.terminate(),
72
+ // triggered by a real timer, not a cooperative clock-check inside the
73
+ // work itself. This is what makes this different from the existing
74
+ // _perFileTimeoutMs / deadlineMs mechanisms.
75
+ const killTimer = setTimeout(() => {
76
+ finish({ ok: false, timedOut: true, timeoutMs, graceMs });
77
+ }, timeoutMs + graceMs);
78
+ killTimer.unref?.();
79
+
80
+ worker.once('message', (msg) => {
81
+ if (msg && msg.ok) finish({ ok: true, result: msg.result });
82
+ else finish({ ok: false, error: (msg && msg.error) || 'unknown worker error' });
83
+ });
84
+
85
+ worker.once('error', (err) => {
86
+ finish({ ok: false, error: String((err && err.message) || err) });
87
+ });
88
+
89
+ worker.once('exit', (code) => {
90
+ finish({ ok: false, error: `worker exited with code ${code} before reporting a result` });
91
+ });
92
+ });
93
+ }