@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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 (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
package/src/mcp/tools.js CHANGED
@@ -16,12 +16,20 @@ 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';
29
+ // Git-origin provenance (Finding Provenance M0/M1). Distinct from
30
+ // `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
31
+ // (Sigstore/SLSA attestation) — see report/index.js's import comment.
32
+ import { redactFindingProvenance } from '../posture/provenance/schema.js';
25
33
 
26
34
  // Lazy-loaded: these transitively pull in npm packages (@babel/core and
27
35
  // friends) that aren't available in the plugin-cache install path
@@ -387,26 +395,44 @@ export const scan_diff = {
387
395
  // / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
388
396
  // full-project deep scan).
389
397
  const runScan = await getRunScan();
390
- const result = await runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true });
398
+ // FR-704 (assurance-hardening PRD): this tool's own description promises
399
+ // "runs scan in memory" — without this, runFullScan's own state writers
400
+ // (dpia.md, ropa.md, privacy-framework.json, threat-model.json, and
401
+ // others) fire unconditionally on every call, silently mutating the
402
+ // user's real project on every pre-write self-correction scan. Confirmed
403
+ // by direct execution before this fix (11 state artifacts written by a
404
+ // single scan_diff-shaped call).
405
+ const result = await withStateWritesDisabled(() =>
406
+ runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
391
407
  const wantSet = new Set(Object.keys(fileContents));
392
408
  const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
393
409
  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 || [])]
410
+ // Stage 6 correctness audit (historical): this used to only read
411
+ // result.scan.findings (the SAST channel) — scan.secrets and
412
+ // scan.logicVulns are separate arrays on the raw runScan() result, and a
413
+ // hand-rolled 3-channel concat here was a second, divergent copy of the
414
+ // merge report/index.js's normalizeFindings() already does (four
415
+ // channels, plus per-channel defaulting and remediation-string
416
+ // resolution the old concat re-implemented separately and could drift
417
+ // from). Assurance-hardening PRD FR-105 ("JSON, SARIF, HTML, CSV, JUnit,
418
+ // and MCP outputs derive from the same validated object"): route through
419
+ // the same canonical merge every other output format uses.
420
+ //
421
+ // This closes the field-mapping/dedup divergence, but does NOT make
422
+ // scan_diff surface SCA/supply-chain findings end to end: this handler
423
+ // never builds a `depFileContents` map (everything a caller passes in
424
+ // `files`, manifests included, lands in `fileContents`), and manifest-
425
+ // based supply-chain detection in engine.js reads only `depFileContents`
426
+ // — so `result.scan.supplyChain` is always empty for this tool today
427
+ // regardless of this fix. That is a separate, real limitation (scan_diff
428
+ // was designed for pre-write code self-correction, not manifest
429
+ // scanning), left as-is rather than silently claimed fixed here.
430
+ const findings = normalizeFindings(result.scan)
405
431
  .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
406
432
  .map(f => redactFinding({
407
433
  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),
434
+ title: f.vuln, cwe: f.cwe,
435
+ description: f.description, remediation: f.remediation,
410
436
  }));
411
437
  // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
412
438
  // The agent gets a head+tail preview plus a path it can page through;
@@ -549,6 +575,21 @@ export const explain_finding = {
549
575
  epssScore: typeof f.epssScore === 'number' ? f.epssScore : null,
550
576
  epssPercentile: typeof f.epssPercentile === 'number' ? f.epssPercentile : null,
551
577
  exploitedNow: !!f.exploitedNow,
578
+ // Which commit introduced this finding. `includeEmail` stays at its
579
+ // DEFAULT (false) unconditionally — unlike the JSON report there is no
580
+ // operator-set env escape for it here, because the consumer is an
581
+ // agent that has no business receiving a committer's email address.
582
+ // `pseudonymize`, by contrast, IS read back from the same env var
583
+ // report/index.js's `_normalizedProvenance` reads
584
+ // (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors) —
585
+ // fix-round item 4: an operator who set that policy was still getting
586
+ // raw committer names (and, via providerEnrichment, raw reviewer
587
+ // logins/CODEOWNERS lines) through this MCP surface because this call
588
+ // passed no options object at all, silently defeating their policy at
589
+ // this one output boundary while report/index.js honoured it.
590
+ findingProvenance: f.findingProvenance ? redactFindingProvenance(f.findingProvenance, {
591
+ pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
592
+ }) : null,
552
593
  };
553
594
  },
554
595
  };
@@ -556,7 +597,7 @@ export const explain_finding = {
556
597
  // ─── apply_fix ───────────────────────────────────────────────────────────────
557
598
  export const apply_fix = {
558
599
  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.',
600
+ 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
601
  inputSchema: {
561
602
  type: 'object',
562
603
  additionalProperties: false,
@@ -597,6 +638,27 @@ export const apply_fix = {
597
638
  partialSanitization: { type: 'boolean' },
598
639
  },
599
640
  },
641
+ // FR-307/FR-1002: this schema had `additionalProperties: false`
642
+ // and never declared `approval` — the property apply-fix-
643
+ // service.js's high-impact-change gate has required since FR-307
644
+ // was built. A real MCP caller supplying fixMeta.approval was
645
+ // rejected by validate.js at the schema layer before the handler
646
+ // ever ran, silently making the approval gate (and FR-1002's
647
+ // identity check layered on it) unreachable from this tool's
648
+ // only real production entry point. See D-0024.
649
+ approval: {
650
+ type: 'object',
651
+ additionalProperties: false,
652
+ properties: {
653
+ approvedBy: { type: 'string', minLength: 1, maxLength: 200 },
654
+ reason: { type: 'string', minLength: 1, maxLength: 1000 },
655
+ },
656
+ },
657
+ // FR-1003: separation-of-duties. Self-reported the same way
658
+ // approvedBy is — this tool has no way to determine who actually
659
+ // wrote a patch, so `author` is a claim, checked against a
660
+ // configurable policy the same way `approval` is.
661
+ author: { type: 'string', minLength: 1, maxLength: 200 },
600
662
  },
601
663
  },
602
664
  },
@@ -671,20 +733,79 @@ export const apply_fix = {
671
733
  verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
672
734
  };
673
735
  }
736
+ // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
737
+ // applyFixHistory() directly and never called applyVerifiedFix() — so
738
+ // the high-impact-change approval gate (auth/authZ/crypto/PII/schema/
739
+ // infra-privilege/public-API) built for the OTHER apply_fix branch
740
+ // (stored fix.replacement) never ran here at all, for any input. Since
741
+ // this is the branch the tool's own description calls the one that
742
+ // covers "~100% of findings that ship only a template," that gap was
743
+ // the larger of the two found this cycle. Same before/after content
744
+ // shape `apply-fix-service.js` already uses — read-first-in-try/catch
745
+ // (D-0012), never existsSync-then-readFileSync.
746
+ const filesForMaterialClassification = {};
747
+ for (const [rel, v] of Object.entries(confinedAbs)) {
748
+ let before = '';
749
+ try { before = await fsp.readFile(v.abs, 'utf8'); } catch { /* new file — before stays '' */ }
750
+ filesForMaterialClassification[rel] = { before, after: v.content };
751
+ }
752
+ const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
674
753
  if (dry_run) {
675
- return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
754
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
755
+ }
756
+ if (materialClassification.highImpactCategories.length) {
757
+ const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
758
+ const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
759
+ typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
760
+ typeof approval.reason === 'string' && approval.reason.trim().length > 0);
761
+ if (!hasApprovalEvidence) {
762
+ return {
763
+ _meta: META, applied: false,
764
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
765
+ materialClassification,
766
+ };
767
+ }
768
+ const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
769
+ const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
770
+ const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
771
+ if (!identityCheck.verified) {
772
+ return {
773
+ _meta: META, applied: false,
774
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
775
+ materialClassification,
776
+ };
777
+ }
778
+ // FR-1003: separation-of-duties, same no-op-unless-configured gate
779
+ // as apply-fix-service.js's own copy — see approver-registry.js.
780
+ const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
781
+ if (!sodCheck.ok) {
782
+ return {
783
+ _meta: META, applied: false,
784
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
785
+ materialClassification,
786
+ };
787
+ }
676
788
  }
677
789
  const written = [];
678
790
  try {
679
791
  for (const [rel, v] of Object.entries(confinedAbs)) {
680
- const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
792
+ const fileExisted = fs.existsSync(v.abs);
793
+ const originalContent = fileExisted ? await fsp.readFile(v.abs, 'utf8') : '';
681
794
  const entry = await applyFixHistory({
682
- scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
795
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
683
796
  findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
797
+ findingProvenance: f.findingProvenance || null,
684
798
  });
685
799
  written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
686
800
  }
687
801
  } catch (e) {
802
+ // FR-306: roll back every file THIS batch already wrote before the
803
+ // failure — applyFixHistory already restored the one file that just
804
+ // failed; this covers the rest, so a multi-file patch never leaves
805
+ // some files patched and others not.
806
+ for (const w of written) {
807
+ try { await revertFixEntry(ctx.sessionRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
808
+ }
688
809
  if (e && e.name === 'FixAttemptBudgetExceededError') {
689
810
  return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
690
811
  }
@@ -692,7 +813,7 @@ export const apply_fix = {
692
813
  }
693
814
  let acceptance = null;
694
815
  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 };
816
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
696
817
  }
697
818
 
698
819
  if (typeof f.fix?.replacement !== 'string') {
@@ -729,41 +850,47 @@ export const apply_fix = {
729
850
  };
730
851
  }
731
852
 
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
- };
853
+ // FR-301/A-08 (assurance-hardening PRD): this branch used to write
854
+ // f.fix.replacement straight to disk with NO fresh verification — no
855
+ // rescan, no lint, nothing confirming the stored replacement actually
856
+ // closes the finding it claims to fix. The caller-patch branch above
857
+ // already required this; there is no reason a STORED fix should be
858
+ // trusted more than a caller-supplied one just because it shipped with
859
+ // the finding. Routed through the same applyVerifiedFix() service the
860
+ // CLI's `fix --apply` now also uses (src/fix/apply-fix-service.js) —
861
+ // confinement/reserved-path are re-checked there too (harmless
862
+ // redundancy with the dry_run preview above, kept for that preview's
863
+ // size-diff shape) but the load-bearing addition is the verification
864
+ // gate before the write.
865
+ if (!f.stableId) {
866
+ return { _meta: META, applied: false, reason: 'finding has no stableId — cannot verify a stored fix against it' };
867
+ }
868
+ const result = await applyVerifiedFix({
869
+ scanRoot: ctx.sessionRoot,
870
+ finding: f,
871
+ files: { [f.file]: f.fix.replacement },
872
+ fixMeta,
873
+ });
874
+ if (!result.ok) {
875
+ if (result.budgetExceeded) {
876
+ return { _meta: META, applied: false, reason: result.reason, budgetExceeded: true, attempts: result.attempts, maxAttempts: result.maxAttempts, key: result.key };
759
877
  }
760
- throw e;
878
+ return { _meta: META, applied: false, reason: result.reason, verify: result.verify || null };
761
879
  }
762
880
  // R25 (PRD §5): surface the running auto-fix acceptance rate after each
763
881
  // applied fix, so the closed loop reports its own success metric.
764
882
  let acceptance = null;
765
883
  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 };
884
+ const entry = result.written[0];
885
+ return {
886
+ // FR-305: verifiedFull distinguishes "every required leg (lint, tests)
887
+ // genuinely ran and passed" from "passed, but a required leg was
888
+ // skipped or unavailable" — verified:true alone conflates them.
889
+ _meta: META, applied: true, verified: true, verifiedFull: result.verifiedFull,
890
+ historyId: entry.historyId, file: entry.file, backupPath: entry.backupPath,
891
+ integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance,
892
+ verify: result.verify,
893
+ };
767
894
  },
768
895
  };
769
896
 
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ // Worker-thread entry point for analyzer-supervisor.js (assurance-hardening
2
+ // PRD FR-202, D-0046 phase 1).
3
+ //
4
+ // Generic by design: workerData names a module and an export to call with a
5
+ // list of plain arguments, so this file has no knowledge of what analyzer it
6
+ // is running. The supervisor is the only caller; this file is not meant to
7
+ // be imported directly.
8
+
9
+ import { parentPort, workerData } from 'node:worker_threads';
10
+
11
+ async function run() {
12
+ try {
13
+ const { modulePath, exportName, args } = workerData;
14
+ const mod = await import(modulePath);
15
+ const fn = mod[exportName];
16
+ if (typeof fn !== 'function') {
17
+ throw new Error(`export "${exportName}" is not a function in ${modulePath}`);
18
+ }
19
+ const result = await fn(...(args || []));
20
+ parentPort.postMessage({ ok: true, result });
21
+ } catch (e) {
22
+ parentPort.postMessage({ ok: false, error: String((e && e.message) || e) });
23
+ }
24
+ }
25
+
26
+ run();
@@ -0,0 +1,33 @@
1
+ // Annotator runner (assurance-hardening PRD, Milestone 0/1, FR-106).
2
+ //
3
+ // Extracted from engine.js's local `_runAnnotator` closure so the Promise-
4
+ // aware error-capture contract can be fault-injection tested directly,
5
+ // without needing a real annotator to actually throw (this codebase's
6
+ // annotators are deliberately built never to — see posture/CLAUDE.md's "no
7
+ // throwing" convention — which is good for production but means there was
8
+ // no natural way to prove the capture path works end-to-end before this
9
+ // module existed). engine.js's `_runAnnotator` is now a thin wrapper that
10
+ // closes over its local `_annotatorErrors` array and delegates here; this
11
+ // module has no engine.js-specific state, so it takes the errors array as a
12
+ // parameter instead of a closure.
13
+ //
14
+ // Contract: a rejection or thrown error from `fn()` — sync or async — is
15
+ // captured as a structured entry, never left to become an unhandled
16
+ // rejection or to race ahead of the caller. The caller MUST `await` this
17
+ // function for the second half of that contract (capture-before-return) to
18
+ // hold; see engine.js's call sites, all of which now do.
19
+
20
+ /**
21
+ * @param {Array<{phase:string, err:string}>} annotatorErrors - mutated in place (push only)
22
+ * @param {string} phase - name recorded on a captured error
23
+ * @param {() => any} fn - sync or async callback
24
+ * @returns {Promise<any>} fn()'s resolved value, or undefined if it threw/rejected
25
+ */
26
+ export async function runAnnotatorAsync(annotatorErrors, phase, fn) {
27
+ try {
28
+ return await fn();
29
+ } catch (e) {
30
+ annotatorErrors.push({ phase, err: String((e && e.message) || e) });
31
+ return undefined;
32
+ }
33
+ }
@@ -0,0 +1,154 @@
1
+ // Assurance modes (assurance-hardening PRD FR-204).
2
+ //
3
+ // "Add assurance modes: advisory, standard, and strict | Strict mode fails
4
+ // when a required analyzer fails, times out, is unavailable, or is
5
+ // silently skipped." Section 12.1 names the CLI surface:
6
+ // `--assurance advisory|standard|strict`.
7
+ //
8
+ // Built directly on FR-203's coverage ledger (pipeline/coverage-ledger.js)
9
+ // and FR-206's scan-health.js -- this module adds no new signal collection
10
+ // of its own, only a POLICY over signals that already exist. That is
11
+ // deliberate: assurance modes decide how strict to be about incomplete
12
+ // analysis; they must never be the thing that DEFINES what "incomplete"
13
+ // means, or the two concepts would drift out of sync.
14
+ //
15
+ // THREE MODES:
16
+ // advisory — never fails the gate over scan health, regardless of what
17
+ // scanHealth reports. Purely informational (surfaced in scanHealth /
18
+ // toShipVerdict / `ci`'s stderr, per FR-206's fix -- this mode does not
19
+ // suppress that, it just does not ADD a build-failing consequence).
20
+ // standard (the DEFAULT, matching this codebase's behavior before and
21
+ // after FR-204) — same as advisory for gate purposes: an incomplete
22
+ // scan is surfaced, never silently hidden, but does not itself fail a
23
+ // build independent of the ordinary --fail-on severity threshold. The
24
+ // distinction from advisory is one of INTENT/reporting emphasis, not
25
+ // mechanism -- see the module-level note below on why this codebase
26
+ // does not invent a mechanical difference the PRD text does not ask
27
+ // for.
28
+ // strict — an incomplete scan (any analyzer failed, timed out, or was
29
+ // skipped by policy -- the ledger's own three non-"completed" outcomes)
30
+ // is a HARD FAILURE, independent of and IN ADDITION TO --fail-on,
31
+ // mirroring cmdCi's own existing precedent for its --policy gate
32
+ // ("Policy runs ALONGSIDE the --fail-on threshold; either gate can
33
+ // fail the build").
34
+ //
35
+ // WHY ADVISORY AND STANDARD ARE MECHANICALLY IDENTICAL HERE: the PRD's own
36
+ // one-line acceptance criterion only specifies STRICT mode's behavior in
37
+ // full ("fails when..."); it does not name a distinct mechanical
38
+ // consequence for standard beyond "the default, not strict." Inventing an
39
+ // intermediate failure condition neither named in the PRD nor requested
40
+ // would be exactly the kind of unrequested scope-widening this codebase's
41
+ // own conventions warn against. If a future requirement needs standard
42
+ // mode to behave differently from advisory, that is a deliberate, separate
43
+ // decision -- not something to guess at here.
44
+
45
+ import { isProvenanceHealthy } from '../posture/provenance/schema.js';
46
+
47
+ export const ASSURANCE_MODES = Object.freeze(['advisory', 'standard', 'strict']);
48
+ export const DEFAULT_ASSURANCE_MODE = 'standard';
49
+
50
+ function _isValidMode(mode) {
51
+ return ASSURANCE_MODES.includes(mode);
52
+ }
53
+
54
+ /**
55
+ * @param {string} mode - one of ASSURANCE_MODES; invalid/missing degrades to the default.
56
+ * @param {object|null} scanHealth - the engine's computed scan.scanHealth (FR-206).
57
+ * @returns {{ok: boolean, mode: string, reason: string|null, conditions: string[]}}
58
+ * ok:false only ever happens in strict mode; advisory/standard always ok:true
59
+ * (they report, they do not gate).
60
+ */
61
+ export function evaluateAssuranceMode(mode, scanHealth, findings = []) {
62
+ const effectiveMode = _isValidMode(mode) ? mode : DEFAULT_ASSURANCE_MODE;
63
+ const conditions = Array.isArray(scanHealth?.conditions) ? scanHealth.conditions : [];
64
+
65
+ if (effectiveMode !== 'strict') {
66
+ return { ok: true, mode: effectiveMode, reason: null, conditions };
67
+ }
68
+
69
+ // Strict: any of the ledger's three non-"completed" analyzer outcomes,
70
+ // OR an annotator error / deep-mode failure (scanHealth's OTHER,
71
+ // non-analyzer conditions) -- "a required analyzer... is unavailable, or
72
+ // is silently skipped" covers more than just coverage-ledger.js's own
73
+ // per-detector accounting; a scan whose ANY known-good signal degraded
74
+ // is not "complete" under strict's own definition.
75
+ const status = scanHealth?.status;
76
+ if (!scanHealth || status !== 'complete') {
77
+ const a = scanHealth?.analyzers;
78
+ const parts = [];
79
+ if (a?.failed > 0) parts.push(`${a.failed} analyzer(s) failed`);
80
+ if (a?.timedOut > 0) parts.push(`${a.timedOut} analyzer(s) timed out`);
81
+ if (a?.skippedByPolicy > 0) parts.push(`${a.skippedByPolicy} analyzer(s) silently skipped by policy`);
82
+ const analyzerSummary = parts.length ? ` (${parts.join(', ')})` : '';
83
+ return {
84
+ ok: false,
85
+ mode: 'strict',
86
+ reason: `strict mode requires a fully complete scan; scanHealth.status is '${status ?? 'unknown'}'${analyzerSummary}`,
87
+ conditions,
88
+ };
89
+ }
90
+
91
+ // M2 §2.5: strict cares about overall scan completeness, which now
92
+ // explicitly includes PROVENANCE completeness, not just detector/analyzer
93
+ // completeness. A finding whose findingProvenance status is outside
94
+ // ['complete','uncommitted'] — including a finding with NO
95
+ // findingProvenance at all, e.g. --no-provenance was used — means strict
96
+ // cannot vouch for this scan's provenance the same way it already refuses
97
+ // to vouch for a scan with a failed analyzer.
98
+ //
99
+ // KNOWN INTERACTION: scan.secrets/scan.logicVulns are unconditionally
100
+ // stamped not_available today (M0+M1 deliberately deferred real origin
101
+ // resolution for those two channels — see the M2/M3/M4 design spec's
102
+ // §2.6). Any real secret or logic finding therefore fails strict mode
103
+ // until that resolution work lands. This is the literal, intended
104
+ // consequence of "never false certainty" applied to strict's own
105
+ // definition, not an oversight — a strict-mode operator with secrets
106
+ // findings should expect this until M3+ closes that gap.
107
+ //
108
+ // This list is INCOMPLETE without scan.supplyChain, and the omission
109
+ // matters more than the secrets/logic one above because it hits nearly
110
+ // every real project. engine.js stamps every supplyChain entry
111
+ // not_available too (see the loop over `supplyChain` right after the
112
+ // `annotateGitProvenance` calls), and that bucket covers three distinct
113
+ // populations, not one:
114
+ //
115
+ // - transitive `vulnerable_dep` findings: a genuine, if currently
116
+ // unresolved, DEFERRAL — same shape as secrets/logicVulns above. The
117
+ // vulnerable version was never declared in this repo's own manifests,
118
+ // so there is no local commit to walk yet, but one could exist to
119
+ // resolve in a later phase.
120
+ // - `unpinned_dep` / `no_lockfile` findings: a CATEGORY ERROR, not a
121
+ // deferral. These describe an ABSENT state (a version range with no
122
+ // pin, a manifest with no lockfile) — there is no "commit that
123
+ // introduced a missing lockfile" for any future resolver to find,
124
+ // because the finding is about the absence of an event, not an event
125
+ // itself. No amount of future engineering work makes these resolvable.
126
+ //
127
+ // Direct `vulnerable_dep` findings DO go through real origin resolution
128
+ // (`resolveDirectSCAOrigin`, gated on `isDirect`) and are not part of this
129
+ // limitation.
130
+ //
131
+ // Net effect: because `unpinned_dep`/`no_lockfile` findings are a category
132
+ // error rather than a deferral, `--assurance strict` will fail on nearly
133
+ // any real project that has a `package.json` (or equivalent manifest)
134
+ // today — an unpinned or unlocked dependency is common, and this check has
135
+ // no way to ever resolve one. This is a known, disclosed limitation of the
136
+ // current implementation, not a bug, and it is not something this check
137
+ // should route around: exempting these finding types from the strict-mode
138
+ // gate was considered and deliberately deferred to a future milestone
139
+ // rather than done here, so strict mode keeps refusing to vouch for
140
+ // provenance it cannot actually speak to.
141
+ const badProvenance = (Array.isArray(findings) ? findings : []).filter((f) => !isProvenanceHealthy(f?.findingProvenance));
142
+ if (badProvenance.length > 0) {
143
+ return {
144
+ ok: false,
145
+ mode: 'strict',
146
+ reason: `strict mode requires complete finding provenance; ${badProvenance.length} finding(s) have status outside [complete, uncommitted]`,
147
+ conditions,
148
+ };
149
+ }
150
+
151
+ return { ok: true, mode: 'strict', reason: null, conditions };
152
+ }
153
+
154
+ export const _internals = { _isValidMode };