@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
@@ -8,6 +8,7 @@ import { createRequire } from 'node:module';
8
8
  const __require = createRequire(import.meta.url);
9
9
  const PKG_VERSION = __require('../package.json').version;
10
10
  import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
11
+ import { isProvenanceHealthy, sanitizeForTerminal } from '../src/posture/provenance/schema.js';
11
12
  import { runScan } from '../src/runScan.js';
12
13
 
13
14
  // Every command is dispatched as `process.exit(await cmdX(args))`, and
@@ -61,14 +62,16 @@ import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
61
62
  import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
62
63
  import { enrichWithEPSS } from '../src/posture/epss.js';
63
64
  import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
64
- import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
65
- import { applyFix, undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
65
+ import { applyCustomRules, runRuleTests, loadCustomRules, customRulesFreshness } from '../src/posture/custom-rules.js';
66
+ import { undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
67
+ import { applyVerifiedFix, confinePath } from '../src/fix/apply-fix-service.js';
66
68
  import { syncTickets } from '../src/integrations/tickets.js';
67
69
  import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
68
70
  import * as triage from '../src/posture/triage.js';
69
71
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
70
72
 
71
73
  import { stateDir, statePath } from '../src/posture/state-dir.js';
74
+ import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
72
75
  // last-scan.json integrity helpers — implementation in posture/integrity.js
73
76
  // so the MCP server tools can share verification.
74
77
  function _verifyLastScan(body, sigFile) {
@@ -103,6 +106,17 @@ Commands:
103
106
  validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
104
107
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
105
108
  reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
109
+ --expired only remove artifacts past their retention-class TTL
110
+ Every run writes a deletion-report.json proving what was planned/deleted/preserved/failed.
111
+ export --out <dir> Copy every present .agentic-security/ artifact to <dir> with a manifest (export-manifest.json,
112
+ unsigned) proving what was exported and what failed — for migration or legal-preservation purposes.
113
+ legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]
114
+ Exempt a registered artifact from retention TTL and reset (both --expired and plain)
115
+ legal-hold remove --artifact <name> Lift a hold
116
+ legal-hold list [--all] List active holds (--all also shows past holds whose expires_at has passed)
117
+ calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]
118
+ Opt-in: report a real-world outcome for a past finding, for calibration validation
119
+ calibration-report [--format cli|json] Aggregated, privacy-preserving calibration report from recorded feedback
106
120
  rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
107
121
  compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
108
122
  --list show bundled + BYO frameworks
@@ -136,6 +150,9 @@ Options:
136
150
  --fail-on-new (ci) block ONLY on findings this PR introduced vs
137
151
  the baseline ref — never the pre-existing backlog
138
152
  --policy <file.rego> ci-mode policy-as-code gate; deny[] rules fail the build (FR-SDLC-9)
153
+ --assurance advisory|standard|strict (ci) incomplete-analysis behavior (default: standard).
154
+ strict fails the build when any analyzer failed, timed out,
155
+ or was silently skipped by policy — independent of --fail-on (FR-204)
139
156
  --columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
140
157
  --confidence <0..1> Override per-profile confidence threshold
141
158
  --firehose Show ALL findings (ignore confidence threshold)
@@ -169,9 +186,20 @@ Options:
169
186
  --no-epss Skip EPSS exploit-prediction enrichment (default: enabled)
170
187
  --no-blast-radius Skip blast-radius / cost framing (default: enabled)
171
188
  --verbose Include fix bodies + taxonomy in CLI output
189
+ (with --firehose, also prints each finding's git-origin provenance)
172
190
  --output <file> Write report to file instead of stdout
173
191
  --machine-output Always write .agentic-security/findings.{sarif,json,csv}
174
192
 
193
+ Finding provenance (which commit introduced each finding):
194
+ --provenance <standard|deep> Resolution depth (default: standard; deep explores non-linear ancestry — merges, reverts, cherry-picks)
195
+ --no-provenance Skip git-history provenance entirely (findings report not_available)
196
+ --provenance-since <ref> Do not walk history earlier than this git ref/commit
197
+ --provenance-timeout <ms> Whole-scan provenance budget in MILLISECONDS (default 60000)
198
+ --include-author-email Keep commit author emails in output (redacted by default)
199
+ --pseudonymize-authors Replace commit author names with a stable Contributor-XXXXXXXX id
200
+ --require-provenance Report unresolved provenance as a scan-health condition
201
+ (downgrades scanHealth.status to 'partial'; never changes the exit code)
202
+
175
203
  Exit codes:
176
204
  0 = clean 1 = low/medium 2 = high 3 = critical 4 = error`;
177
205
 
@@ -377,6 +405,112 @@ function parseArgs(argv) {
377
405
  return args;
378
406
  }
379
407
 
408
+ // The only values `--provenance` accepts. Used both to validate an inline
409
+ // `--provenance=<mode>` and to decide whether a following bare token is this
410
+ // flag's value at all — see the comment inside the parser.
411
+ const PROVENANCE_MODES = new Set(['standard', 'deep']);
412
+
413
+ // Finding Provenance (M0/M1) — CLI flags that set the env vars engine.js
414
+ // (Task 15) and report/index.js (Task 16) already read:
415
+ // AGENTIC_SECURITY_NO_PROVENANCE, AGENTIC_SECURITY_PROVENANCE_MODE,
416
+ // AGENTIC_SECURITY_PROVENANCE_SINCE, AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS,
417
+ // AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL,
418
+ // AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS. `requireProvenance` is consumed
419
+ // directly by cmdScan (post-scan scanHealth augmentation), not via an env var.
420
+ // Kept as a pure function (argv in, plain object out) so it's unit-testable
421
+ // without invoking the CLI dispatch or touching process.env.
422
+ export function parseProvenanceFlags(argv) {
423
+ // OPT-IN, not opt-out (0.145.0). Provenance was on by default through M0-M4,
424
+ // and the release gate caught what that costs: time-to-first-finding on a
425
+ // 207-file tree went 4.5s -> 45s, a 7.6x regression on the ONE metric
426
+ // bench/ttff/runner.mjs's own header calls the binding constraint for this
427
+ // product's vibecoder ICP ("how long until the FIRST useful result, not
428
+ // aggregate F1"). Resolving history for every finding is simply not what a
429
+ // first-time user is waiting for. It stays one flag away, and CI/compliance
430
+ // callers that DO want it pass `--provenance` explicitly.
431
+ //
432
+ // `disabled` therefore starts true, and any provenance-shaped flag flips it
433
+ // off — asking for `--provenance-since` or `--require-provenance` is asking
434
+ // for provenance, and making the user also pass a bare `--provenance`
435
+ // alongside it would be a papercut with no upside.
436
+ const result = { mode: 'standard', since: null, timeoutMs: undefined, includeEmail: false, pseudonymize: false, requireProvenance: false, disabled: true, warning: null };
437
+ const warnings = [];
438
+ for (let i = 0; i < argv.length; i++) {
439
+ const a = argv[i];
440
+ if (typeof a !== 'string' || !a.startsWith('--')) continue;
441
+ // BOTH `--flag value` and `--flag=value`, because parseArgs() above accepts
442
+ // both for every other flag in this CLI and an operator has no way to know
443
+ // this one parser is different. Exact-string matching silently ignored
444
+ // `--provenance=deep` / `--provenance-since=v1.0.0` /
445
+ // `--provenance-timeout=30000` — no warning, defaults quietly used.
446
+ //
447
+ // Split on the FIRST `=` and keep the whole remainder, rather than
448
+ // parseArgs's `split('=', 2)` which drops everything after a second `=`.
449
+ // A git ref (`--provenance-since=refs/tags/v1=rc1`) is a legal value and
450
+ // truncating it would be a worse failure than the one being fixed.
451
+ const eq = a.indexOf('=');
452
+ const key = eq === -1 ? a : a.slice(0, eq);
453
+ const inline = eq === -1 ? undefined : a.slice(eq + 1);
454
+ // `--flag value` consumes the NEXT argv entry only when there is no inline
455
+ // value; a value that itself starts with `--` is another flag, not this
456
+ // one's argument.
457
+ const takeValue = () => {
458
+ if (inline !== undefined) return inline;
459
+ const next = argv[i + 1];
460
+ if (next !== undefined && !String(next).startsWith('--')) { i++; return next; }
461
+ return undefined;
462
+ };
463
+
464
+ if (key === '--no-provenance') result.disabled = true;
465
+ else if (key === '--provenance') {
466
+ result.disabled = false;
467
+ // The SPACE form only claims the next token when it actually names a
468
+ // mode. `--provenance` is legal on its own (provenance is on by default;
469
+ // the flag is how you say "standard, explicitly"), and this CLI's target
470
+ // is nearly always a positional — `scan --provenance ./src` must scan
471
+ // `./src`, not report `unrecognised mode './src'`. parseArgs() does its
472
+ // own positional collection over the same argv, so consuming here never
473
+ // steals the path from it, but the warning would still be a lie.
474
+ //
475
+ // The INLINE form has no such ambiguity: `--provenance=x` can only ever
476
+ // be a mode, so an unknown one is a typo worth naming.
477
+ const v = inline !== undefined
478
+ ? inline
479
+ : (PROVENANCE_MODES.has(String(argv[i + 1])) ? argv[++i] : undefined);
480
+ if (v === 'deep' || v === 'standard' || v === undefined) {
481
+ result.mode = v || 'standard';
482
+ } else {
483
+ warnings.push(`unrecognised --provenance mode '${v}' (expected standard|deep), running standard`);
484
+ }
485
+ } else if (key === '--provenance-since') { result.since = takeValue() ?? null; result.disabled = false; }
486
+ else if (key === '--provenance-timeout') {
487
+ // MILLISECONDS, and validated as such. `parseInt` alone turned
488
+ // `--provenance-timeout 30s` into 30 — a 30-MILLISECOND budget that
489
+ // expires before the first `git blame` returns, so every finding came
490
+ // back `budget_exhausted` and nothing said why. A missing value produced
491
+ // NaN, which `if (_provFlags.timeoutMs)` then discarded silently. Both
492
+ // now warn and fall back to the engine default rather than inventing a
493
+ // budget the operator did not ask for. The unit is fixed by the env var
494
+ // this flag feeds, AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS.
495
+ const raw = takeValue();
496
+ if (raw === undefined) {
497
+ warnings.push('--provenance-timeout requires a value in milliseconds; using the default budget');
498
+ } else if (!/^\d+$/.test(String(raw).trim()) || parseInt(raw, 10) <= 0) {
499
+ warnings.push(`--provenance-timeout expects a positive integer number of MILLISECONDS, got '${raw}'; using the default budget`);
500
+ } else {
501
+ result.timeoutMs = parseInt(raw, 10);
502
+ result.disabled = false;
503
+ }
504
+ }
505
+ else if (key === '--include-author-email') { result.includeEmail = true; result.disabled = false; }
506
+ else if (key === '--pseudonymize-authors') { result.pseudonymize = true; result.disabled = false; }
507
+ else if (key === '--require-provenance') { result.requireProvenance = true; result.disabled = false; }
508
+ }
509
+ // One field, so a caller that prints `warning` cannot drop the second one.
510
+ result.warning = warnings.length ? warnings.join('; ') : null;
511
+ return result;
512
+ }
513
+
380
514
  async function cmdScan(args) {
381
515
  // NON_MUTATING_SCAN_PRD S1 — a scan is an observation; --no-state makes it one.
382
516
  if (args.flags['no-state']) {
@@ -472,12 +606,67 @@ async function cmdScan(args) {
472
606
  process.stderr.write(`[pr-mode] scanning files changed since: ${changedSince}\n`);
473
607
  }
474
608
 
609
+ // Finding Provenance (M0/M1) — translate --provenance/--no-provenance/etc.
610
+ // into the env vars engine.js already reads. Must run before runScan()
611
+ // below, same as the --deep/--no-deep wiring above.
612
+ const _provFlags = parseProvenanceFlags(process.argv.slice(2));
613
+ if (_provFlags.warning) console.error(`Warning: ${_provFlags.warning}`);
614
+ if (_provFlags.disabled) process.env.AGENTIC_SECURITY_NO_PROVENANCE = '1';
615
+ process.env.AGENTIC_SECURITY_PROVENANCE_MODE = _provFlags.mode;
616
+ if (_provFlags.since) process.env.AGENTIC_SECURITY_PROVENANCE_SINCE = _provFlags.since;
617
+ if (_provFlags.timeoutMs) process.env.AGENTIC_SECURITY_PROVENANCE_TIMEOUT_MS = String(_provFlags.timeoutMs);
618
+ if (_provFlags.includeEmail) process.env.AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL = '1';
619
+ if (_provFlags.pseudonymize) process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS = '1';
620
+
475
621
  const { scan, meta } = await runScan(target, {
476
622
  changedSince,
477
623
  onProgress: (p) => {
478
624
  if (process.stderr.isTTY) process.stderr.write(`\r[${p.phase}] ${p.current}/${p.total} ${p.file} `);
479
625
  },
480
626
  });
627
+ // --require-provenance: flag (never fail) any finding whose provenance
628
+ // isn't resolved, via scanHealth — deliberately independent of the
629
+ // severity-based exit code computed by exitCodeFor() at the end of this
630
+ // function. 'uncommitted' is a legitimate terminal status (the finding is
631
+ // in a file with no git history yet), not an incomplete one.
632
+ if (_provFlags.requireProvenance) {
633
+ // ALL FOUR channels (scanner/CLAUDE.md: findings / secrets / supplyChain /
634
+ // logicVulns). Checking only `scan.findings` made the flag report a clean
635
+ // bill of provenance health for a scan whose SCA and secrets findings had
636
+ // none — and report/index.js's normalizeFindings ships all four to the
637
+ // user as findings, so "every finding" has to mean all four here too.
638
+ const incomplete = [];
639
+ for (const [channel, bucket] of [
640
+ ['findings', scan.findings], ['secrets', scan.secrets],
641
+ ['supplyChain', scan.supplyChain], ['logicVulns', scan.logicVulns],
642
+ ]) {
643
+ for (const f of (bucket || [])) {
644
+ if (!f || typeof f !== 'object') continue;
645
+ if (isProvenanceHealthy(f.findingProvenance)) continue;
646
+ incomplete.push(f.id || f.stableId || `${channel}:${f.name || f.file || f.type || 'entry'}`);
647
+ }
648
+ }
649
+ if (incomplete.length > 0) {
650
+ // Written the way EVERY other scan-health signal is written — a sentence
651
+ // in `conditions[]` plus a `complete` -> `partial` status demotion (see
652
+ // pipeline/scan-health.js's applyFreshness, the same "patch it on from
653
+ // bin/ after the engine already built scanHealth" pattern). The previous
654
+ // version set a bespoke `scanHealth.provenanceIncomplete` key that NO
655
+ // consumer reads: pipeline/assurance-mode.js and
656
+ // posture/compliance-policy.js both read `status`/`conditions[]` only, so
657
+ // --require-provenance changed no behaviour anywhere. The array is still
658
+ // carried, for a consumer that wants the ids, but it is no longer the
659
+ // only trace.
660
+ const condition = `--require-provenance: ${incomplete.length} finding(s) have unresolved provenance`;
661
+ scan.scanHealth = {
662
+ ...(scan.scanHealth || {}),
663
+ conditions: [...(Array.isArray(scan.scanHealth?.conditions) ? scan.scanHealth.conditions : []), condition],
664
+ status: (scan.scanHealth?.status ?? 'complete') === 'complete' ? 'partial' : scan.scanHealth.status,
665
+ provenanceIncomplete: incomplete,
666
+ };
667
+ }
668
+ }
669
+
481
670
  // The BOM/attestation emitters stamp the producing engine's version into
482
671
  // their metadata; carry the real package version so it can never drift.
483
672
  if (meta && meta.engineVersion == null) meta.engineVersion = PKG_VERSION;
@@ -600,6 +789,18 @@ async function cmdScan(args) {
600
789
  }
601
790
  } catch {}
602
791
 
792
+ // FR-207: custom rule pack freshness. This is the one freshness leg that
793
+ // cannot be computed inside engine.js -- the pattern-rule DSL only runs
794
+ // here, after scan.scanHealth already exists -- so it's patched on via
795
+ // the same applyFreshness() engine.js uses for the other four legs (see
796
+ // that function's header comment in pipeline/scan-health.js).
797
+ if (scan.scanHealth) {
798
+ try {
799
+ const { applyFreshness } = await import('../src/pipeline/scan-health.js');
800
+ scan.scanHealth = applyFreshness(scan.scanHealth, { customRules: customRulesFreshness(targetAbs) });
801
+ } catch {}
802
+ }
803
+
603
804
  // EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
604
805
  // Bumps severity on actively-exploited CVEs so they sort to the top.
605
806
  if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
@@ -717,7 +918,20 @@ async function cmdScan(args) {
717
918
  // per-finding list (with inline why-it-matters / how-it-fires / fix depth) so
718
919
  // "Show ALL findings" actually shows them. Add --verbose for full narration + code.
719
920
  if (args.flags.firehose && (!format || format === 'ship' || format === 'summary')) {
720
- body += '\n\n' + toCLI(scan, { verbose });
921
+ // `provenance` reuses --verbose rather than adding a seventh provenance
922
+ // flag: --verbose already means "print the extra per-finding narration"
923
+ // (explainParts's why/how/fix bodies), and the provenance block is exactly
924
+ // that kind of detail. Before this, explainProvenance()/toCLI's
925
+ // `{provenance}` option had NO production caller at all — it was reachable
926
+ // only from its own unit test, so a feature that was built, reviewed and
927
+ // shipped could never be seen by a user.
928
+ //
929
+ // Gated on provenance actually having run, too: with --no-provenance (or
930
+ // AGENTIC_SECURITY_NO_PROVENANCE=1) every finding carries a `not_available`
931
+ // record, and printing five lines of "we did not look" per finding is noise
932
+ // the operator explicitly opted out of.
933
+ const provenanceOn = !_provFlags.disabled && process.env.AGENTIC_SECURITY_NO_PROVENANCE !== '1';
934
+ body += '\n\n' + toCLI(scan, { verbose, provenance: verbose && provenanceOn });
721
935
  }
722
936
 
723
937
  // v3 next-gen — supplementary blocks for human-readable formats. These
@@ -975,8 +1189,23 @@ async function cmdCi(args) {
975
1189
  const findings = normalizeFindings(scan);
976
1190
  const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
977
1191
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
1192
+ // FR-206: "reports show both finding count and scan-health status." A
1193
+ // 0-finding scan-exit is not the same claim as "the scan actually
1194
+ // finished cleanly" — an annotator error, a file timeout, or a skipped
1195
+ // analyzer already demotes scan.scanHealth.status to 'partial'
1196
+ // (scan-health.js), but until this line nothing in `ci`'s own printed
1197
+ // report ever surfaced it, so a broken analyzer could pass CI silently.
1198
+ // This is a REPORT fix, not a gate-policy change: whether an incomplete
1199
+ // scan should itself FAIL the build regardless of --fail-on is FR-204's
1200
+ // job (assurance modes advisory/standard/strict), not yet implemented —
1201
+ // deliberately not conflated with this fix.
1202
+ const _health = scan.scanHealth;
1203
+ const _healthLine = (_health && _health.status && _health.status !== 'complete')
1204
+ ? `[ci] ⚠ scan-health=${_health.status} — ${(_health.conditions || [])[0] || 'analysis did not complete cleanly'}${(_health.conditions || []).length > 1 ? ` (+${_health.conditions.length - 1} more)` : ''}\n`
1205
+ : `[ci] scan-health=${_health && _health.status ? _health.status : 'unknown'}\n`;
978
1206
  process.stderr.write(
979
1207
  `[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
1208
+ _healthLine +
980
1209
  (_canWriteCi
981
1210
  ? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
982
1211
  : `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
@@ -1000,6 +1229,23 @@ async function cmdCi(args) {
1000
1229
  }
1001
1230
  process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
1002
1231
  }
1232
+ // FR-204: assurance modes. Runs ALONGSIDE --fail-on and --policy, same
1233
+ // precedent as FR-SDLC-9's policy gate above — any of the three can fail
1234
+ // the build independently. `standard` (the default) never fails here;
1235
+ // `strict` fails the build when the scan itself was not fully complete,
1236
+ // regardless of how few/no findings resulted from the incomplete run.
1237
+ const { evaluateAssuranceMode, ASSURANCE_MODES, DEFAULT_ASSURANCE_MODE } = await import('../src/pipeline/assurance-mode.js');
1238
+ const assuranceMode = args.flags.assurance || DEFAULT_ASSURANCE_MODE;
1239
+ if (!ASSURANCE_MODES.includes(assuranceMode)) {
1240
+ console.error(`[ci] --assurance must be one of: ${ASSURANCE_MODES.join('|')} (got '${assuranceMode}')`);
1241
+ return 1;
1242
+ }
1243
+ const assuranceVerdict = evaluateAssuranceMode(assuranceMode, scan.scanHealth, findings);
1244
+ if (!assuranceVerdict.ok) {
1245
+ console.error(`[ci] assurance gate FAILED (mode=${assuranceMode}): ${assuranceVerdict.reason}`);
1246
+ return 1;
1247
+ }
1248
+ if (assuranceMode === 'strict') process.stderr.write(`[ci] assurance gate PASSED (mode=strict)\n`);
1003
1249
  // R24 (PRD §5): PR-native net-new gate. With --fail-on-new and a baseline
1004
1250
  // ref, block ONLY on findings this PR INTRODUCED (vs the base ref), never on
1005
1251
  // the pre-existing backlog — the posture teams actually leave enabled.
@@ -1142,6 +1388,17 @@ async function cmdTriage(args) {
1142
1388
  console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
1143
1389
  if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
1144
1390
  console.log(` Total open: ${t.totalOpen}`);
1391
+ // FR-907: longitudinal production feedback — aggregates 5 already-
1392
+ // separate mechanisms (user suppression, accepted risk, invalid
1393
+ // finding, fixed finding, verification outcome) into one view, rather
1394
+ // than requiring an operator to check 5 different files by hand.
1395
+ const { productionFeedbackReport, renderProductionFeedbackSummary } = await import('../src/posture/production-feedback.js');
1396
+ const feedback = productionFeedbackReport(target, { sinceDays: days });
1397
+ const feedbackSummary = renderProductionFeedbackSummary(feedback);
1398
+ if (feedbackSummary) {
1399
+ console.log('');
1400
+ console.log(feedbackSummary);
1401
+ }
1145
1402
  return 0;
1146
1403
  }
1147
1404
  console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
@@ -1586,57 +1843,263 @@ async function cmdReset(args) {
1586
1843
  console.log(`No state to reset at ${stateDirPath}`);
1587
1844
  return 0;
1588
1845
  }
1589
- const WIPE = new Set([
1590
- 'validator-metrics.json',
1591
- 'triage-feedback.json',
1592
- 'scan-history.json',
1593
- 'last-scan.json',
1594
- 'last-scan.json.sig',
1595
- 'shadow-findings.json',
1596
- 'mcp-audit.log',
1597
- 'hook-throttle.json',
1598
- 'tickets.json',
1599
- 'streak.json',
1600
- 'findings.json',
1601
- 'findings.sarif',
1602
- 'findings.csv',
1603
- ]);
1604
- const WIPE_DIRS = new Set([
1605
- 'llm-cache',
1606
- 'fix-history',
1607
- 'fix-plans',
1608
- ]);
1846
+ // FR-703 (assurance-hardening PRD): registry-driven, not enumeration-
1847
+ // driven. The registry (src/posture/artifact-registry.js) is built from an
1848
+ // audit of every state-writing call site in src/ and bin/, not a
1849
+ // hand-maintained list that drifts as new artifacts are added — see that
1850
+ // module's header for the classification rules (generated vs
1851
+ // operator-config) and the corrections it made to this project's own PRD
1852
+ // evidence table along the way.
1853
+ const GENERATED = new Set(listGeneratedArtifacts().map(a => a.name));
1609
1854
  const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
1610
- const targets = [];
1611
- for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1612
- if (keep.has(entry.name)) continue;
1613
- if (WIPE.has(entry.name) || WIPE_DIRS.has(entry.name)) {
1614
- targets.push({ name: entry.name, dir: entry.isDirectory() });
1855
+ // FR-702: `--expired` narrows the reset to only artifacts that are past
1856
+ // their retention-class TTL (cache/scan/evidence/ticket/backup see
1857
+ // posture/retention-policy.js), rather than every registered generated
1858
+ // artifact. Reuses this same command's existing confirm/keep/preserve
1859
+ // machinery — a second, parallel deletion pathway would be a second
1860
+ // place to get path-safety wrong.
1861
+ let targets;
1862
+ if (args.flags.expired) {
1863
+ const { findExpiredArtifacts } = await import('../src/posture/retention-policy.js');
1864
+ targets = findExpiredArtifacts(scanRoot)
1865
+ .filter(a => !keep.has(a.name))
1866
+ .map(a => ({ name: a.name, dir: a.isDir, ageDays: a.ageDays, ttlDays: a.ttlDays, retentionClass: a.retentionClass }));
1867
+ } else {
1868
+ targets = [];
1869
+ for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1870
+ if (keep.has(entry.name)) continue;
1871
+ if (GENERATED.has(entry.name)) {
1872
+ targets.push({ name: entry.name, dir: entry.isDirectory() });
1873
+ }
1615
1874
  }
1616
1875
  }
1876
+ // FR-707: an artifact under an active legal hold is pulled OUT of the
1877
+ // deletion targets regardless of mode — a hold must protect against a
1878
+ // PLAIN `reset --yes` too (which otherwise deletes every registered
1879
+ // 'generated' artifact unconditionally), not just `--expired`, which
1880
+ // retention-policy.js's own findExpiredArtifacts already excludes on its
1881
+ // own as a second, defense-in-depth enforcement of the same guarantee.
1882
+ const { loadLegalHolds, isUnderHold } = await import('../src/posture/legal-hold.js');
1883
+ const holds = loadLegalHolds(scanRoot);
1884
+ const heldDetail = [];
1885
+ targets = targets.filter(t => {
1886
+ const hold = isUnderHold(t.name, holds);
1887
+ if (!hold) return true;
1888
+ heldDetail.push({ name: t.name, dir: !!t.dir, reason: `active legal hold (owner: ${hold.owner}, reason: ${hold.reason})` });
1889
+ return false;
1890
+ });
1617
1891
  if (!targets.length) {
1618
- console.log(`Nothing to reset under ${stateDirPath}.`);
1892
+ console.log(args.flags.expired ? `Nothing expired under ${stateDirPath}.` : `Nothing to reset under ${stateDirPath}.`);
1893
+ if (heldDetail.length) {
1894
+ console.log(`Preserving ${heldDetail.length} artifact(s) under active legal hold: ${heldDetail.map(h => h.name).sort().join(', ')}`);
1895
+ }
1619
1896
  return 0;
1620
1897
  }
1621
- console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
1622
- for (const t of targets) console.log(` ${t.name}${t.dir ? '/' : ''}`);
1898
+ console.log(`agentic-security reset${args.flags.expired ? ' --expired' : ''} — will remove from ${stateDirPath}:`);
1899
+ for (const t of targets) {
1900
+ const ttlNote = args.flags.expired ? ` (${t.retentionClass}, ${t.ageDays.toFixed(1)}d old, ttl ${t.ttlDays}d)` : '';
1901
+ console.log(` ${t.name}${t.dir ? '/' : ''}${ttlNote}`);
1902
+ }
1623
1903
  console.log('');
1624
- console.log('Preserving operator-authored config: rules.yml, rules/, license-policy.yml, trusted-keys.json, ruleset-version.json');
1904
+ // FR-703: report what is ACTUALLY present and being left alone, rather
1905
+ // than a hardcoded 5-name string that drifted behind the real config
1906
+ // surface (risk-config.yml, profile.yml, suppressions.yml, and others were
1907
+ // silently omitted from the old message even though they were already
1908
+ // correctly never wiped).
1909
+ const targetNames = new Set(targets.map(t => t.name));
1910
+ const heldNames = new Set(heldDetail.map(h => h.name));
1911
+ const preserved = heldDetail.map(h => h.name + (h.dir ? '/' : ''));
1912
+ const preservedDetail = heldDetail.map(h => ({ name: h.name, reason: h.reason }));
1913
+ for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1914
+ if (keep.has(entry.name) || targetNames.has(entry.name) || heldNames.has(entry.name)) continue;
1915
+ if (!args.flags.expired && GENERATED.has(entry.name)) continue;
1916
+ preserved.push(entry.name + (entry.isDirectory() ? '/' : ''));
1917
+ preservedDetail.push({
1918
+ name: entry.name,
1919
+ reason: args.flags.expired && GENERATED.has(entry.name)
1920
+ ? 'generated artifact still within its retention TTL'
1921
+ : 'operator-authored configuration',
1922
+ });
1923
+ }
1924
+ if (preserved.length) {
1925
+ console.log(args.flags.expired
1926
+ ? `Preserving operator-authored config and generated artifacts still within their TTL: ${preserved.sort().join(', ')}`
1927
+ : `Preserving operator-authored config: ${preserved.sort().join(', ')}`);
1928
+ }
1929
+ // FR-706: every reset invocation — dry-run or applied — leaves a durable,
1930
+ // structured record of what it planned, deleted, preserved, or failed to
1931
+ // delete, not just console output that vanishes once the terminal
1932
+ // scrolls past it.
1933
+ const { buildDeletionReport, writeDeletionReport } = await import('../src/posture/state-lifecycle-report.js');
1934
+ const mode = args.flags.expired ? 'reset --expired' : 'reset';
1625
1935
  if (!args.flags.yes) {
1626
1936
  console.log('');
1627
1937
  console.log('Pass --yes to proceed (or --keep <name,name> to spare specific items).');
1938
+ writeDeletionReport(scanRoot, buildDeletionReport({
1939
+ mode, dryRun: true, root: scanRoot,
1940
+ items: targets.map(t => ({
1941
+ name: t.name, dir: !!t.dir, status: 'planned',
1942
+ retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null,
1943
+ })),
1944
+ preserved: preservedDetail,
1945
+ }));
1628
1946
  return 0;
1629
1947
  }
1948
+ const outcomes = [];
1630
1949
  for (const t of targets) {
1631
1950
  const p = path.join(stateDirPath, t.name);
1951
+ const base = { name: t.name, dir: !!t.dir, retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null };
1632
1952
  try {
1633
1953
  if (t.dir) await fsp.rm(p, { recursive: true, force: true });
1634
1954
  else await fsp.rm(p, { force: true });
1955
+ outcomes.push({ ...base, status: 'deleted' });
1635
1956
  } catch (e) {
1636
1957
  console.error(`reset: failed to remove ${p}: ${e.message}`);
1958
+ outcomes.push({ ...base, status: 'failed', error: e.message });
1637
1959
  }
1638
1960
  }
1639
- console.log(`Reset ${targets.length} item(s). Operator-authored config preserved.`);
1961
+ const failedCount = outcomes.filter(o => o.status === 'failed').length;
1962
+ console.log(`Reset ${outcomes.length - failedCount} item(s)${failedCount ? `, ${failedCount} failed` : ''}. Operator-authored config preserved.`);
1963
+ const reportPath = writeDeletionReport(scanRoot, buildDeletionReport({
1964
+ mode, dryRun: false, root: scanRoot, items: outcomes, preserved: preservedDetail,
1965
+ }));
1966
+ if (reportPath) console.log(`Deletion report: ${path.relative(scanRoot, reportPath)}`);
1967
+ return failedCount ? 1 : 0;
1968
+ }
1969
+
1970
+ // `agentic-security export --out <dir> [--root <path>]`
1971
+ //
1972
+ // FR-706 (assurance-hardening PRD): the "exported" half of "operators can
1973
+ // prove what was exported, deleted, retained, or failed." Copies every
1974
+ // CURRENTLY-PRESENT registered artifact — generated AND operator-config —
1975
+ // from .agentic-security/ into an operator-chosen destination, alongside a
1976
+ // manifest naming exactly what was copied (with a content hash for files)
1977
+ // and what failed. Unlike `reset`, classification does not gate inclusion:
1978
+ // an export is a snapshot for the operator's own records, migration, or
1979
+ // legal-preservation purposes, not a deletion decision.
1980
+ async function cmdExport(args) {
1981
+ const scanRoot = path.resolve(args.flags.root || '.');
1982
+ const stateDirPath = stateDir(scanRoot);
1983
+ if (!fs.existsSync(stateDirPath)) {
1984
+ console.log(`No state to export at ${stateDirPath}`);
1985
+ return 0;
1986
+ }
1987
+ if (!args.flags.out) {
1988
+ console.error('export: --out <dir> is required.');
1989
+ return 2;
1990
+ }
1991
+ const outDir = path.resolve(args.flags.out);
1992
+ await fsp.mkdir(outDir, { recursive: true });
1993
+
1994
+ const { ARTIFACT_REGISTRY } = await import('../src/posture/artifact-registry.js');
1995
+ const { buildExportReport, writeExportReport } = await import('../src/posture/state-lifecycle-report.js');
1996
+ const { createHash } = await import('node:crypto');
1997
+ const present = new Set((await fsp.readdir(stateDirPath, { withFileTypes: true })).map(e => e.name));
1998
+
1999
+ const items = [];
2000
+ for (const artifact of ARTIFACT_REGISTRY) {
2001
+ if (!present.has(artifact.name)) continue;
2002
+ const src = path.join(stateDirPath, artifact.name);
2003
+ const dest = path.join(outDir, artifact.name);
2004
+ try {
2005
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
2006
+ if (artifact.kind === 'dir') {
2007
+ await fsp.cp(src, dest, { recursive: true });
2008
+ items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256: null });
2009
+ } else {
2010
+ await fsp.copyFile(src, dest);
2011
+ const sha256 = createHash('sha256').update(fs.readFileSync(src)).digest('hex');
2012
+ items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256 });
2013
+ }
2014
+ } catch (e) {
2015
+ items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'failed', error: e.message });
2016
+ }
2017
+ }
2018
+
2019
+ const report = buildExportReport({ root: scanRoot, outDir, items });
2020
+ const manifestPath = path.join(outDir, 'export-manifest.json');
2021
+ await fsp.writeFile(manifestPath, JSON.stringify(report, null, 2) + '\n');
2022
+ writeExportReport(scanRoot, report); // last-action record kept under .agentic-security/ too, same as deletion-report.json
2023
+
2024
+ const failedCount = items.filter(i => i.status === 'failed').length;
2025
+ console.log(`Exported ${items.length - failedCount} artifact(s) to ${outDir}${failedCount ? `, ${failedCount} failed` : ''}.`);
2026
+ console.log(`Manifest: ${manifestPath}`);
2027
+ return failedCount ? 1 : 0;
2028
+ }
2029
+
2030
+ // `agentic-security legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]`
2031
+ // `agentic-security legal-hold remove --artifact <name>`
2032
+ // `agentic-security legal-hold list [--all]`
2033
+ //
2034
+ // FR-707 (assurance-hardening PRD): identity-bound (--owner), reasoned
2035
+ // (--reason), time-bounded where applicable (--expires is optional — an
2036
+ // indefinite hold has no end date), and auditable (`list` reads back
2037
+ // exactly what was recorded). See posture/legal-hold.js for the full
2038
+ // design rationale and how this is enforced inside `cmdReset`.
2039
+ async function cmdLegalHold(args) {
2040
+ const scanRoot = path.resolve(args.flags.root || '.');
2041
+ const sub = args._[1];
2042
+ const { addLegalHold, removeLegalHold, listLegalHolds } = await import('../src/posture/legal-hold.js');
2043
+
2044
+ if (sub === 'add') {
2045
+ const result = addLegalHold(scanRoot, {
2046
+ artifact: args.flags.artifact, owner: args.flags.owner, reason: args.flags.reason, expires_at: args.flags.expires,
2047
+ });
2048
+ if (!result.ok) { console.error(`legal-hold add: ${result.reason}`); return 2; }
2049
+ console.log(`Legal hold placed on "${result.hold.artifact}" (owner: ${result.hold.owner}${result.hold.expires_at ? `, expires ${result.hold.expires_at}` : ', indefinite'}).`);
2050
+ return 0;
2051
+ }
2052
+ if (sub === 'remove') {
2053
+ if (!args.flags.artifact) { console.error('legal-hold remove: --artifact <name> is required.'); return 2; }
2054
+ const removedCount = removeLegalHold(scanRoot, args.flags.artifact);
2055
+ console.log(removedCount ? `Removed ${removedCount} hold(s) on "${args.flags.artifact}".` : `No hold found on "${args.flags.artifact}".`);
2056
+ return 0;
2057
+ }
2058
+ if (sub === 'list' || !sub) {
2059
+ const holds = listLegalHolds(scanRoot, { includeExpired: !!args.flags.all });
2060
+ if (!holds.length) { console.log(args.flags.all ? 'No legal holds recorded (ever).' : 'No active legal holds.'); return 0; }
2061
+ for (const h of holds) {
2062
+ console.log(` ${h.artifact} owner=${h.owner} reason="${h.reason}" expires=${h.expires_at || 'never'} created=${h.created_at}`);
2063
+ }
2064
+ return 0;
2065
+ }
2066
+ console.error(`legal-hold: unknown subcommand "${sub}" (expected add|remove|list).`);
2067
+ return 2;
2068
+ }
2069
+
2070
+ // `agentic-security calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]`
2071
+ // `agentic-security calibration-report`
2072
+ //
2073
+ // FR-806 (assurance-hardening PRD): genuinely opt-in — nothing here is ever
2074
+ // written by a scan. See posture/calibration-feedback.js for the full
2075
+ // scoping rationale (why "aggregated" means within-installation, why the
2076
+ // record snapshots only prediction signals and never file/line/vuln text).
2077
+ async function cmdCalibrationFeedback(args) {
2078
+ const scanRoot = path.resolve(args.flags.root || '.');
2079
+ const sub = args._[1];
2080
+ const { recordCalibrationFeedback, OUTCOMES } = await import('../src/posture/calibration-feedback.js');
2081
+
2082
+ if (sub === 'record') {
2083
+ const result = recordCalibrationFeedback(scanRoot, {
2084
+ findingId: args.flags['finding-id'], outcome: args.flags.outcome, note: args.flags.note,
2085
+ });
2086
+ if (!result.ok) { console.error(`calibration-feedback record: ${result.reason}`); return 2; }
2087
+ // The persisted record.findingId is privacy-safe (a hash), not the
2088
+ // caller's own input -- echo back what the operator actually typed.
2089
+ console.log(`Recorded "${result.record.outcome}" for finding ${args.flags['finding-id']}.`);
2090
+ return 0;
2091
+ }
2092
+ console.error(`calibration-feedback: unknown subcommand "${sub}" (expected record --finding-id <id> --outcome ${OUTCOMES.join('|')} [--note <text>]).`);
2093
+ return 2;
2094
+ }
2095
+
2096
+ async function cmdCalibrationReport(args) {
2097
+ const scanRoot = path.resolve(args.flags.root || '.');
2098
+ const { buildCalibrationReport, renderCalibrationReportSummary } = await import('../src/posture/calibration-feedback.js');
2099
+ const report = buildCalibrationReport(scanRoot);
2100
+ if (args.flags.format === 'json') { writeStdout(JSON.stringify(report, null, 2) + '\n'); return 0; }
2101
+ const summary = renderCalibrationReportSummary(report);
2102
+ console.log(summary || 'No calibration feedback recorded yet — this is opt-in; see `calibration-feedback record --help`.');
1640
2103
  return 0;
1641
2104
  }
1642
2105
 
@@ -1885,6 +2348,53 @@ async function cmdCompliance(args) {
1885
2348
 
1886
2349
  async function cmdAttest(args) {
1887
2350
  const scanRoot = path.resolve(args.flags.root || '.');
2351
+
2352
+ if (args.flags.provenance) {
2353
+ const {
2354
+ buildProvenanceEvidenceBundle, signProvenanceEvidenceBundle, ensureKeyPair,
2355
+ } = await import('../src/posture/provenance-evidence-bundle.js');
2356
+
2357
+ let scan;
2358
+ try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
2359
+ catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
2360
+
2361
+ const findings = scan.findings || [];
2362
+ const wanted = args.flags.provenance === true ? undefined : args.flags.provenance;
2363
+ // `--provenance` alone (boolean flag) attests every finding WITH
2364
+ // findingProvenance present; `--provenance <id>` scopes to one.
2365
+ const subset = (wanted ? findings.filter((f) => f.id === wanted || f.stableId === wanted) : findings)
2366
+ .filter((f) => f.findingProvenance);
2367
+ if (!subset.length) {
2368
+ console.error(wanted ? `No finding matching "${wanted}" with findingProvenance.` : 'No findings with findingProvenance to attest.');
2369
+ return 2;
2370
+ }
2371
+
2372
+ const kp = ensureKeyPair();
2373
+ if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
2374
+
2375
+ const outDir = statePath(scanRoot, 'attestations');
2376
+ fs.mkdirSync(outDir, { recursive: true });
2377
+ // repoIdentity: best-effort, from the same `git remote` lookup other
2378
+ // provenance modules avoid (no such lookup exists yet) — keep it simple,
2379
+ // pass null when unavailable rather than inventing a git-remote reader
2380
+ // here. A future task can enrich this; the field degrades honestly.
2381
+ const meta = { engineVersion: scan.engineVersion || null, repoIdentity: null, head: scan.commit || null };
2382
+
2383
+ let n = 0;
2384
+ for (const f of subset) {
2385
+ const bundle = signProvenanceEvidenceBundle(buildProvenanceEvidenceBundle(f, meta), kp.privateKeyPem);
2386
+ const name = `provenance-${(f.stableId || f.id || `finding-${n}`)}.json`.replace(/[^\w.-]/g, '_');
2387
+ fs.writeFileSync(path.join(outDir, name), JSON.stringify(bundle, null, 2) + '\n');
2388
+ n++;
2389
+ }
2390
+ console.log(`Signed ${n} provenance evidence bundle(s) → ${path.relative(scanRoot, outDir)}/`);
2391
+ console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
2392
+ console.log('');
2393
+ console.log('A bundle proves its contents are unmodified since signing. It does NOT');
2394
+ console.log('prove the origin commit is correctly identified — read confidence.level.');
2395
+ return 0;
2396
+ }
2397
+
1888
2398
  const {
1889
2399
  ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
1890
2400
  } = await import('../src/posture/evidence-bundle.js');
@@ -1989,8 +2499,15 @@ async function cmdVerifyAttestation(args) {
1989
2499
  if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
1990
2500
 
1991
2501
  let bundle;
1992
- try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
1993
- catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
2502
+ try {
2503
+ const raw = fs.readFileSync(path.resolve(file), 'utf8');
2504
+ // FR-705: transparently decrypt if this is an encrypted artifact (e.g.
2505
+ // an encrypted compliance-evidence.json) — a no-op for any plaintext
2506
+ // file, including every artifact from before encryption was ever
2507
+ // configured.
2508
+ const { maybeDecryptForRead } = await import('../src/posture/encryption-provider.js');
2509
+ bundle = JSON.parse(maybeDecryptForRead(raw));
2510
+ } catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
1994
2511
 
1995
2512
  const runAttestation = _asRunAttestation(bundle);
1996
2513
  if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
@@ -2000,6 +2517,55 @@ async function cmdVerifyAttestation(args) {
2000
2517
  try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
2001
2518
  catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
2002
2519
 
2520
+ // FR-505: a compliance evidence manifest (@type: ComplianceEvidence) is a
2521
+ // third distinct shape this same command can be handed — auto-detected
2522
+ // the same way run-attestation-vs-finding-bundle already is, rather than
2523
+ // adding a fourth CLI command for what is, from an operator's point of
2524
+ // view, the same question ("is this artifact exactly what was signed").
2525
+ if (bundle['@type'] === 'ComplianceEvidence') {
2526
+ const { verifyComplianceEvidence } = await import('../src/posture/compliance-evidence-signing.js');
2527
+ const cr = verifyComplianceEvidence(bundle, publicKeyPem);
2528
+ if (!cr.ok) { console.error(`✗ INVALID — ${cr.reason}`); return 1; }
2529
+ console.log('✓ VALID — the compliance evidence manifest is exactly what the signer produced.');
2530
+ console.log('');
2531
+ console.log(` framework: ${bundle.framework} version: ${bundle.version}`);
2532
+ console.log(` generated: ${bundle.generatedAt}`);
2533
+ if (bundle.evidenceDigest) console.log(` evidence digest: ${bundle.evidenceDigest}`);
2534
+ console.log(` compliant: ${bundle.summary?.compliant ?? 'n/a'} non-compliant: ${bundle.summary?.nonCompliant ?? 'n/a'} stale: ${bundle.summary?.stale ?? 0} gap: ${bundle.summary?.gap ?? 0}`);
2535
+ return 0;
2536
+ }
2537
+
2538
+ // Finding Provenance PRD M4 §4.1: a provenance evidence bundle
2539
+ // (schema: agentic-security/provenance-evidence@1) is a fourth distinct
2540
+ // shape this same command can be handed — same auto-detection chain as
2541
+ // the ComplianceEvidence branch above, dispatched by schema marker
2542
+ // rather than a new CLI verb. Must be checked BEFORE the fallback
2543
+ // verifyEvidenceBundle() call below, which assumes evidence-bundle.js's
2544
+ // own shape (`.evidence`, `.finding` with severity/vuln/file/line) and
2545
+ // would misinterpret a provenance bundle.
2546
+ const { verifyProvenanceEvidenceBundle, PROVENANCE_BUNDLE_SCHEMA } = await import('../src/posture/provenance-evidence-bundle.js');
2547
+ if (bundle.schema === PROVENANCE_BUNDLE_SCHEMA) {
2548
+ const pr = verifyProvenanceEvidenceBundle(bundle, publicKeyPem);
2549
+ if (!pr.ok) { console.error(`✗ INVALID — ${pr.reason}`); return 1; }
2550
+ const p = bundle.provenance || {};
2551
+ console.log('✓ VALID — the provenance record is exactly what the signer attested.');
2552
+ console.log('');
2553
+ console.log(` finding: ${bundle.finding?.stableId || bundle.finding?.id || '?'}`);
2554
+ console.log(` status: ${p.status || 'n/a'} method: ${p.method || 'n/a'}`);
2555
+ // FR-PROV-026: findingOrigin.authorName is untrusted git commit metadata
2556
+ // (this bundle's signature only proves the BUNDLE wasn't tampered with —
2557
+ // it says nothing about what the original commit author put in their
2558
+ // name — see the "Signed, portable evidence" section of the root
2559
+ // CLAUDE.md), printed straight to the terminal by `verify-attestation`.
2560
+ if (p.findingOrigin) console.log(` origin: ${p.findingOrigin.commit || '?'} by ${sanitizeForTerminal(p.findingOrigin.authorName) || '?'} on ${p.findingOrigin.authorDate || '?'}`);
2561
+ console.log(` confidence: ${p.confidence?.level || 'n/a'} (${p.confidence?.score ?? 'n/a'})`);
2562
+ if ((p.limitations || []).length) console.log(` limitations: ${p.limitations.join('; ')}`);
2563
+ console.log('');
2564
+ console.log(` proves: ${bundle.proves}`);
2565
+ console.log(` does NOT prove: ${bundle.doesNotProve}`);
2566
+ return 0;
2567
+ }
2568
+
2003
2569
  const r = verifyEvidenceBundle(bundle, publicKeyPem);
2004
2570
  if (!r.ok) {
2005
2571
  console.error(`✗ INVALID — ${r.reason}`);
@@ -2016,6 +2582,84 @@ async function cmdVerifyAttestation(args) {
2016
2582
  return 0;
2017
2583
  }
2018
2584
 
2585
+ // FR-1001 (assurance-hardening PRD): "effective policy is explainable."
2586
+ // Loads whichever organization/repository/environment policy bundles exist
2587
+ // under .agentic-security/policy-bundles/, verifies each against an
2588
+ // operator-supplied public key, merges the valid ones (most-specific-wins),
2589
+ // and prints BOTH the effective policy with per-key provenance AND every
2590
+ // rejected bundle's scope and reason — a rejection is reported, never
2591
+ // silently absent, so "tampered or expired policy is rejected" is visible
2592
+ // through this same real command, not just a library-level guarantee.
2593
+ async function cmdPolicyExplain(args) {
2594
+ const scanRoot = path.resolve(args.flags.root || '.');
2595
+ const { loadPolicyBundles, loadPolicyPublicKey, resolveEffectivePolicy } = await import('../src/posture/policy-bundle.js');
2596
+ const entries = loadPolicyBundles(scanRoot);
2597
+ if (!entries.length) {
2598
+ console.log('No policy bundles found under .agentic-security/policy-bundles/ (organization.json, repository.json, environment.json).');
2599
+ return 0;
2600
+ }
2601
+ let publicKeyPem = null;
2602
+ const keyFile = args.flags['public-key'];
2603
+ if (keyFile) {
2604
+ try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
2605
+ catch (e) { console.error(`Could not read public key at ${keyFile}: ${e.message}`); return 2; }
2606
+ } else {
2607
+ publicKeyPem = loadPolicyPublicKey(scanRoot);
2608
+ }
2609
+ const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
2610
+
2611
+ console.log(`Accepted (${accepted.length}): ${accepted.join(', ') || 'none'}`);
2612
+ if (rejected.length) {
2613
+ console.log(`Rejected (${rejected.length}):`);
2614
+ for (const r of rejected) console.log(` ✗ ${r.scope}: ${r.reason}`);
2615
+ }
2616
+ console.log('');
2617
+ console.log('Effective policy:');
2618
+ const keys = Object.keys(effective).sort();
2619
+ if (!keys.length) {
2620
+ console.log(' (empty — no accepted bundle contributed any key)');
2621
+ } else {
2622
+ for (const k of keys) console.log(` ${k} = ${JSON.stringify(effective[k])} [from: ${provenance[k]}]`);
2623
+ }
2624
+ return 0;
2625
+ }
2626
+
2627
+ // FR-1001: the operator-facing signing side, mirroring cmdAttest's own
2628
+ // generate-key-if-absent pattern. Genuinely optional — an org can sign
2629
+ // bundles with any Ed25519 tooling that produces the same canonical bytes
2630
+ // (canonicalPolicyBytes is exported for exactly that interop reason) — but
2631
+ // without SOME real caller for ensurePolicyKeyPair, this codebase's own
2632
+ // dead-module guard is right to flag it: a key-generation function nobody
2633
+ // calls is exactly the kind of code this project's premortems exist to
2634
+ // catch, per posture/CLAUDE.md's dead-module convention.
2635
+ async function cmdPolicySign(args) {
2636
+ const scanRoot = path.resolve(args.flags.root || '.');
2637
+ const { ensurePolicyKeyPair, buildPolicyBundle, signPolicyBundle } = await import('../src/posture/policy-bundle.js');
2638
+ const scope = args.flags.scope;
2639
+ if (!['organization', 'repository', 'environment'].includes(scope)) {
2640
+ console.error('Usage: agentic-security policy-sign --scope <organization|repository|environment> --policy <json-file> [--expires <ISO-date>] [--out <path>]');
2641
+ return 2;
2642
+ }
2643
+ const policyFile = args.flags.policy;
2644
+ if (!policyFile) { console.error('--policy <json-file> is required (the policy object to sign).'); return 2; }
2645
+ let policy;
2646
+ try { policy = JSON.parse(fs.readFileSync(path.resolve(policyFile), 'utf8')); }
2647
+ catch (e) { console.error(`Could not read --policy file: ${e.message}`); return 2; }
2648
+
2649
+ const kp = ensurePolicyKeyPair();
2650
+ if (kp.created) console.error(`Generated a new policy-signing key at ${kp.privateKey} (public: ${kp.publicKey}). Distribute the PUBLIC key to every repository that must trust bundles you sign.`);
2651
+
2652
+ const bundle = buildPolicyBundle(scope, policy, { expiresAt: args.flags.expires || null });
2653
+ if (!bundle) { console.error('Could not build a bundle — check --scope and that --policy is a JSON object.'); return 2; }
2654
+ const signed = signPolicyBundle(bundle, kp.privateKeyPem);
2655
+
2656
+ const outFile = args.flags.out || `${scope}.json`;
2657
+ fs.writeFileSync(path.resolve(outFile), JSON.stringify(signed, null, 2) + '\n');
2658
+ console.log(`✓ signed ${scope} policy bundle written to ${outFile}`);
2659
+ console.log(` public key for verification: ${kp.publicKey}`);
2660
+ return 0;
2661
+ }
2662
+
2019
2663
  async function cmdRuleSynth(args) {
2020
2664
  const scanRoot = path.resolve(args.flags.root || '.');
2021
2665
  const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
@@ -2099,11 +2743,15 @@ async function cmdFix(args) {
2099
2743
  return 0;
2100
2744
  }
2101
2745
 
2102
- // Both --preview and --apply require an actual replacement to operate on.
2103
- // For now we accept either f.fix.replacement (full new file content) or
2104
- // f.fix.replaceLine (single-line replacement). Anything else falls back
2105
- // to the template output and tells the user to run the security-fixer subagent.
2106
- const absFile = path.resolve(scanRoot, f.file);
2746
+ // FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
2747
+ // before the write a traversal or symlink-planted f.file was previously
2748
+ // followed unquestioned for both the preview diff and the apply write (no
2749
+ // check existed on this path at all, unlike MCP's apply_fix). Applies to
2750
+ // preview too since both modes read from the same (until now, unconfined)
2751
+ // location.
2752
+ let absFile;
2753
+ try { absFile = confinePath(scanRoot, f.file, 'finding.file'); }
2754
+ catch (e) { console.error(`path-escape refused: ${e.message}`); return 4; }
2107
2755
  if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
2108
2756
  const originalContent = await fsp.readFile(absFile, 'utf8');
2109
2757
  let newContent = null;
@@ -2127,16 +2775,56 @@ async function cmdFix(args) {
2127
2775
  return 0;
2128
2776
  }
2129
2777
 
2130
- // --apply. Premortem 4R-8: pass stableId from the engine directly so the
2131
- // recover() cross-check is robust against line-number drift (f.id is
2132
- // `${file}:${line}:${rule}` and rotates when the user edits the file).
2133
- const entry = await applyFix({
2134
- scanRoot, file: f.file, originalContent, newContent,
2135
- findingId: f.id, stableId: f.stableId || null,
2136
- ruleId: f.cwe || f.title, vuln: f.vuln || f.title,
2778
+ // --apply. FR-301/FR-302/FR-304 (assurance-hardening PRD): this used to
2779
+ // WARN on failed integrity and apply anyway, and skip verification
2780
+ // entirely (no rescan, no lint) — the same write-time safety gate MCP's
2781
+ // apply_fix already enforces for its caller-patch branch is now required
2782
+ // here too, through the one shared service.
2783
+ if (sigVerified !== true) {
2784
+ console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
2785
+ return 4;
2786
+ }
2787
+ // FR-307/D-0024: before this, the CLI had no way to supply approval
2788
+ // evidence at all — a high-impact change (auth/authZ/crypto/PII/schema/
2789
+ // infra-privilege/public-API) was unconditionally refused via --apply
2790
+ // with no path to ever approve it, since fixMeta was never built here.
2791
+ // --approved-by/--approval-reason are optional and no-op for a candidate
2792
+ // that isn't high-impact — only apply-fix-service.js's own gate decides
2793
+ // whether they were needed.
2794
+ const approvedBy = args.flags['approved-by'] || null;
2795
+ const approvalReason = args.flags['approval-reason'] || null;
2796
+ // FR-1003: --author is optional and only has an effect when an operator
2797
+ // has opted into separation-of-duties in authorized-approvers.json — see
2798
+ // approver-registry.js's checkSeparationOfDuties.
2799
+ const patchAuthor = args.flags['author'] || null;
2800
+ const fixMeta = (approvedBy || approvalReason || patchAuthor)
2801
+ ? { approval: { approvedBy: approvedBy || '', reason: approvalReason || '' }, ...(patchAuthor ? { author: patchAuthor } : {}) }
2802
+ : null;
2803
+ const result = await applyVerifiedFix({
2804
+ scanRoot,
2805
+ finding: { file: f.file, id: f.id, stableId: f.stableId || null, ruleId: f.cwe || f.title, vuln: f.vuln || f.title },
2806
+ files: { [f.file]: newContent },
2807
+ fixMeta,
2137
2808
  });
2138
- console.log(`✓ applied fix ${entry.id} (file: ${entry.file})`);
2809
+ if (!result.ok) {
2810
+ console.error(`Refusing to apply: ${result.reason}`);
2811
+ if (result.budgetExceeded) console.error(` (${result.attempts}/${result.maxAttempts} attempts already made for this finding)`);
2812
+ return 4;
2813
+ }
2814
+ const entry = result.written[0];
2815
+ console.log(`✓ applied fix ${entry.historyId} (file: ${entry.file})`);
2139
2816
  console.log(` backup: ${entry.backupPath}`);
2817
+ // FR-305: this used to unconditionally print "lint" as part of the
2818
+ // verified line, even when the linter was skipped (not installed) or no
2819
+ // test runner existed — claiming a required leg ran when it did not is
2820
+ // exactly the mislabeling FR-305 exists to prevent. Say plainly when the
2821
+ // pass was degraded and name what was skipped.
2822
+ if (result.verifiedFull) {
2823
+ console.log(' verified: yes — fully verified (rescan clean, no new medium+ finding, lint, tests)');
2824
+ } else {
2825
+ const degraded = (result.verify?.degradedLegs || []).join('; ') || 'a required leg';
2826
+ console.log(` verified: yes, but NOT fully verified — rescan clean, no new medium+ finding; ${degraded}`);
2827
+ }
2140
2828
  console.log(` revert with: agentic-security undo`);
2141
2829
  return 0;
2142
2830
  }
@@ -2308,10 +2996,16 @@ async function main() {
2308
2996
  case 'validator-cache': process.exit(await cmdValidatorCache(args));
2309
2997
  case 'verify': process.exit(await cmdVerify(args));
2310
2998
  case 'reset': process.exit(await cmdReset(args));
2999
+ case 'export': process.exit(await cmdExport(args));
3000
+ case 'legal-hold': process.exit(await cmdLegalHold(args));
3001
+ case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
3002
+ case 'calibration-report': process.exit(await cmdCalibrationReport(args));
2311
3003
  case 'hunt': process.exit(await cmdHunt(args));
2312
3004
  case 'compliance': process.exit(await cmdCompliance(args));
2313
3005
  case 'attest': process.exit(await cmdAttest(args));
2314
3006
  case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
3007
+ case 'policy-explain': process.exit(await cmdPolicyExplain(args));
3008
+ case 'policy-sign': process.exit(await cmdPolicySign(args));
2315
3009
  case 'rule-synth': process.exit(await cmdRuleSynth(args));
2316
3010
  case 'digest': process.exit(await cmdDigest(args));
2317
3011
  case 'setup': process.exit(await cmdSetup(args));
@@ -2517,4 +3211,31 @@ async function main() {
2517
3211
  }
2518
3212
  }
2519
3213
 
2520
- main();
3214
+ // Guard against running as the CLI entry point vs. being `import`ed (e.g. by
3215
+ // scanner/test/cli/provenance-flags.test.js, which imports parseProvenanceFlags
3216
+ // for a unit test). Without this, any import of this module — for a single
3217
+ // named export — re-runs the entire CLI dispatch and calls process.exit(),
3218
+ // killing whatever process did the importing.
3219
+ //
3220
+ // `import.meta.url === file://${process.argv[1]}` looks equivalent but is
3221
+ // NOT: when this script is invoked through a symlink (exactly what
3222
+ // `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
3223
+ // package's `bin` entries — which is the documented `npx
3224
+ // @clear-capabilities/agentic-security-scanner` install path), Node
3225
+ // resolves `import.meta.url` to the symlink's realpath while
3226
+ // `process.argv[1]` stays the symlink path as invoked, so the two never
3227
+ // match, the guard is always false, and the CLI silently exits with no
3228
+ // output. `import.meta.main` is resolved correctly through a symlink —
3229
+ // verified live through an actual symlink, not just read about — see the
3230
+ // Task 17 fix report. It was added in Node v24.2.0 (backported to
3231
+ // v22.18.0) and is currently Stability 1.0 (early development) per Node's
3232
+ // own docs — NOT stable, and NOT available on v20.11 as an earlier
3233
+ // version of this comment incorrectly claimed. Concretely: it is
3234
+ // `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
3235
+ // `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
3236
+ // reproduce this exact bug (main() silently never runs) on a plain
3237
+ // non-symlinked invocation under those two point releases. The `??`
3238
+ // fallback below covers that gap without bumping the engines floor.
3239
+ if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
3240
+ main();
3241
+ }