@clear-capabilities/agentic-security-scanner 0.142.0 → 0.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/bin/agentic-security.js +530 -54
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +83 -18
  93. package/src/report/oscal.js +635 -0
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -25,6 +25,7 @@
25
25
  // `unverified-no-llm-endpoint` and the transcript records only the seed input.
26
26
 
27
27
  import * as crypto from 'node:crypto';
28
+ import { evaluateEgress } from '../egress/policy.js';
28
29
 
29
30
  const MAX_CALLS_DEFAULT = 50;
30
31
  const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
@@ -170,12 +171,23 @@ export async function runAgent(finding, opts = {}) {
170
171
  const transcript = startTranscript(finding, opts.target);
171
172
  const budget = { maxCalls: opts.maxCalls, maxWallMs: opts.maxWallMs };
172
173
 
173
- const llmInvoke = opts.llmInvoke || (process.env.AGENTIC_SECURITY_LLM_ENDPOINT ? defaultLlmInvoke : null);
174
+ // FR-601: evaluated before defaultLlmInvoke is even selected, so a denial
175
+ // means its transcript-to-prompt construction (inside defaultLlmInvoke,
176
+ // called later in the loop below) never runs at all.
177
+ let egressDecision = null;
178
+ let llmInvoke = opts.llmInvoke || null;
179
+ if (!llmInvoke && process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
180
+ egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: process.env.AGENTIC_SECURITY_LLM_ENDPOINT });
181
+ if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
182
+ }
174
183
  const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
175
184
 
176
185
  if (typeof llmInvoke !== 'function' || typeof executeTool !== 'function') {
177
- appendEntry(transcript, { phase: 'init', reason: 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set' });
178
- return { transcript, outcome: 'unverified-no-llm-endpoint' };
186
+ const reason = (egressDecision && !egressDecision.allowed)
187
+ ? `egress policy denied this call: ${egressDecision.reason}`
188
+ : 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
189
+ appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
190
+ return { transcript, outcome: 'unverified-no-llm-endpoint', egressDecision };
179
191
  }
180
192
 
181
193
  let outcome = null;
@@ -0,0 +1,217 @@
1
+ // State artifact registry (assurance-hardening PRD, Milestone 0/1, FR-701/FR-703).
2
+ //
3
+ // `bin/agentic-security.js`'s `cmdReset` used to delete from two hardcoded
4
+ // Sets (WIPE / WIPE_DIRS) — an enumeration that had drifted badly behind the
5
+ // state artifacts this codebase actually writes under `.agentic-security/`.
6
+ // This module is the registry FR-701 asks for: every known artifact, with an
7
+ // explicit classification, built by auditing every `statePath(...)` call
8
+ // site in `src/` and `bin/` (not guessed from filenames — several looked
9
+ // like generated output by name but turned out, on reading their actual
10
+ // read/write call sites, to be operator- or agent-authored INPUT).
11
+ //
12
+ // Two classifications:
13
+ // - 'generated': written by the scanner itself, safe to delete — the next
14
+ // scan/command regenerates it. `cmdReset` removes these by default.
15
+ // - 'operator-config': hand-authored (or agent-authored, for
16
+ // logic-claims.json) input the scanner only reads. Deleting it on reset
17
+ // would be data loss, not cleanup — `cmdReset` always preserves these.
18
+ //
19
+ // Corrections this audit made to the assurance-hardening PRD's own evidence
20
+ // table (A-10), which had assumed these were straightforwardly "missing from
21
+ // the wipe list, therefore should be added": `logic-claims.json` is read-only
22
+ // from engine.js (an external reviewing agent authors it); `current-intent.md`
23
+ // has no writer anywhere in src/ or bin/ (developer-authored);
24
+ // `exploit-history.jsonl`'s own header comment calls it an "operator-curated
25
+ // record"; `cve-alerts.json`'s own header comment calls it "Configuration...
26
+ // read from"; `network-policy.json` is documented as an Inputs-section
27
+ // artifact in network-policy-import.js. All five are classified
28
+ // 'operator-config' here — the registry closes the reset-completeness gap
29
+ // without introducing a NEW data-loss bug in the process.
30
+ //
31
+ // This module implements FR-701 (this registry) and FR-703 (registry-driven
32
+ // reset). FR-702 (TTL by artifact class) is implemented here too, as an
33
+ // additive `retentionClass` field: 'cache' | 'scan' | 'evidence' | 'ticket'
34
+ // | 'backup' | undefined, matching the acceptance criterion's own named
35
+ // list verbatim ("expired caches, scans, evidence, tickets, and backups").
36
+ // DELIBERATELY CONSERVATIVE: only entries that unambiguously fit one of
37
+ // those five categories carry a class. Ongoing accumulated state whose
38
+ // deletion would be a real loss rather than cleanup — calibration data
39
+ // (validator-metrics.json, triage-feedback.json), the continual-learning
40
+ // memory file (AGENTS.md), daemon dedup state (cve-alerts-state.json), the
41
+ // gamification streak counter, an operator-set regression baseline
42
+ // (baseline.json, set via --set-baseline — functionally closer to
43
+ // operator intent than scanner output even though it is written by the
44
+ // scanner) — are left with NO retention class rather than forced into the
45
+ // nearest-sounding bucket. A class-less 'generated' artifact is completely
46
+ // unaffected by FR-702's enforcement; it is still deleted unconditionally
47
+ // by an ordinary `reset` (FR-703's own behavior, unchanged).
48
+ //
49
+ // See retention-policy.js for the default/max TTL values per class, the
50
+ // optional operator-override file, and the actual expiry check — kept in
51
+ // a separate module rather than grown into this one, the same "registry
52
+ // vs. policy" separation this session's egress/policy.js and
53
+ // compliance-policy.js already establish.
54
+ //
55
+ // FR-706 (export/deletion manifests) is implemented in
56
+ // posture/state-lifecycle-report.js, consuming this registry directly (an
57
+ // export walks the FULL registry, not just the 'generated' half `reset`
58
+ // acts on — see that module's header).
59
+ //
60
+ // FR-705 (encryption of confidential state classes): an additive
61
+ // `confidential: true` field, enforced by posture/encryption-provider.js.
62
+ // DELIBERATELY CONSERVATIVE, Phase 1 of a staged rollout (see that
63
+ // module's own header for the full rationale): only `compliance-
64
+ // evidence.json`/`.md` are marked in this pass — both have exactly one,
65
+ // well-isolated writer function (compliance-policy.js's emitEvidenceJsonLd/
66
+ // emitEvidenceMarkdown) and zero other production readers besides that
67
+ // same module and the verify-attestation CLI path, both already updated to
68
+ // transparently decrypt. Candidates explicitly DEFERRED, with reasons:
69
+ // `mcp-audit.log`/`egress-audit.log` (hash-CHAINED NDJSON — each entry's
70
+ // hash covers the previous entry, so per-write whole-file encryption would
71
+ // need the chain to be computed over plaintext before encrypting, a real
72
+ // design question left for its own pass), `dpia.md`/`ropa.md`/`data-
73
+ // inventory.json` (written via engine.js's own internal helper, a larger
74
+ // blast radius to verify safely), `last-scan.json`/`findings.json` (read
75
+ // directly as plain JSON by dozens of commands — encrypting these needs a
76
+ // decrypt-on-read hook at every one of those call sites, a much larger,
77
+ // separate migration in the same spirit as E2's own deferred scope).
78
+
79
+ export const ARTIFACT_REGISTRY = [
80
+ // ── Generated: scan output, caches, and system-maintained ledgers ──────
81
+ { name: 'validator-metrics.json', kind: 'file', classification: 'generated' },
82
+ { name: 'triage-feedback.json', kind: 'file', classification: 'generated' },
83
+ { name: 'scan-history.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
84
+ { name: 'last-scan.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
85
+ { name: 'last-scan.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
86
+ { name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
87
+ { name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
88
+ { name: 'egress-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: "FR-604 per-call egress audit log — hash-chained NDJSON written by egress/audit.js's recordEgressCall, never read as config" },
89
+ { name: 'hook-throttle.json', kind: 'file', classification: 'generated', retentionClass: 'cache' },
90
+ { name: 'tickets.json', kind: 'file', classification: 'generated', retentionClass: 'ticket' },
91
+ { name: 'streak.json', kind: 'file', classification: 'generated' },
92
+ { name: 'findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
93
+ { name: 'findings.sarif', kind: 'file', classification: 'generated', retentionClass: 'scan' },
94
+ { name: 'findings.csv', kind: 'file', classification: 'generated', retentionClass: 'scan' },
95
+ { name: 'llm-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache' },
96
+ { name: 'fix-history', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
97
+ { name: 'fix-plans', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
98
+ // The following were confirmed missing from the old hardcoded WIPE/
99
+ // WIPE_DIRS sets (A-10) and confirmed GENERATED by reading their write
100
+ // call sites (engine.js, or the module named in `source`).
101
+ { name: 'dpia.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState)' },
102
+ { name: 'ropa.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-407 RoPA scaffold, dataflow/privacy-governance.js' },
103
+ { name: 'data-inventory.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 code-derived data inventory, dataflow/privacy-inventory.js' },
104
+ { name: 'data-flow-graph.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 mermaid flow graph, dataflow/privacy-inventory.js' },
105
+ { name: 'privacy-framework.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
106
+ { name: 'privacy-framework.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
107
+ { name: 'ifds-summaries.json', kind: 'file', classification: 'generated', retentionClass: 'cache', source: 'dataflow/ifds-precise.js (cache)' },
108
+ { name: 'exploit-bundles.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'engine.js (_safeWriteState)' },
109
+ { name: 'cve-alerts-state.json', kind: 'file', classification: 'generated', source: 'posture/cve-alert-daemon.js' },
110
+ { name: 'compliance-evidence.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
111
+ { name: 'compliance-evidence.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
112
+ { name: 'ATTRIBUTIONS.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
113
+ { name: 'NOTICE', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
114
+ { name: 'accepted.json', kind: 'file', classification: 'generated', source: 'posture/suppressions.js (soft-accept save path)', note: 'already self-managing per-entry expiry (FR-1004-adjacent) — no additional class-level TTL' },
115
+ { name: 'triage.json', kind: 'file', classification: 'generated', source: 'posture/triage.js (_save)' },
116
+ { name: 'pqc-migration-plan.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
117
+ { name: 'pqc-migration-plan.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
118
+ // controls.json lives under compliance/<framework>/ — registering the
119
+ // parent directory covers it and any other per-framework artifact.
120
+ { name: 'compliance', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/auditor-walkthrough.js' },
121
+ { name: 'attestations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/evidence-bundle.js' },
122
+ { name: 'auditor-walkthroughs', kind: 'dir', classification: 'generated', retentionClass: 'evidence' },
123
+ { name: 'incremental', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'dataflow/incremental.js (cache)' },
124
+ { name: 'model-rescan', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
125
+ { name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
126
+ { name: 'scan-baselines', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'posture/pr-augment.js' },
127
+ { name: 'agent-scratchpad', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'mcp/tools.js (append_scratchpad)' },
128
+ { name: 'AGENTS.md', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
129
+ { name: 'AGENTS.md.archive', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
130
+ { name: 'baseline.json', kind: 'file', classification: 'generated', source: 'bin/agentic-security.js (--set-baseline)', note: 'operator-set intent, functionally closer to operator-config than scan output — no auto-expiry' },
131
+ // These two were found by the completeness guard (test/artifact-registry-
132
+ // completeness.test.js) to have a read call site (leaderboard.js,
133
+ // posture/findings-memory.js respectively) but NO writer anywhere in src/
134
+ // or bin/ — likely dead/aspirational read paths from a scan-history
135
+ // storage scheme that was refactored away. Registered as 'generated'
136
+ // rather than left unclassified: nothing about a per-scan history log/
137
+ // directory suggests hand-authored config, so if a future change starts
138
+ // writing either, the safe default (delete on reset, like scan-history.json)
139
+ // is already in place rather than accidentally falling to operator-config.
140
+ { name: 'scan-history.jsonl', kind: 'file', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
141
+ { name: 'scan-history', kind: 'dir', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
142
+
143
+ // FR-706: the last-action proof artifacts `reset` and `export` write —
144
+ // see posture/state-lifecycle-report.js's header for why each is a single
145
+ // overwritten "last action" file rather than an ever-growing log.
146
+ { name: 'deletion-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdReset)' },
147
+ { name: 'export-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdExport)' },
148
+
149
+ // ── Operator-config: hand-authored (or agent-authored) input, never wiped ──
150
+ { name: 'rules.yml', kind: 'file', classification: 'operator-config' },
151
+ { name: 'rules', kind: 'dir', classification: 'operator-config' },
152
+ { name: 'rules-proposed', kind: 'dir', classification: 'operator-config', note: 'proposed rules awaiting human review — not yet approved config, but not scanner-regenerable either' },
153
+ { name: 'license-policy.yml', kind: 'file', classification: 'operator-config' },
154
+ { name: 'trusted-keys.json', kind: 'file', classification: 'operator-config' },
155
+ { name: 'ruleset-version.json', kind: 'file', classification: 'operator-config', note: 'pinning intent, hand-set' },
156
+ { name: 'risk-config.yml', kind: 'file', classification: 'operator-config' },
157
+ { name: 'egress-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-601 egress policy (mode: allow/deny/local-only, allowedProviders/deniedProviders) — read by egress/policy.js, never written by the scanner' },
158
+ { name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
159
+ { name: 'profile.yml', kind: 'file', classification: 'operator-config' },
160
+ { name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
161
+ { name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
162
+ { name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
163
+ { name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
164
+ { name: 'privacy-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-404 privacy sink policy (which class-to-sink flows are explicitly permitted) — read by dataflow/privacy-sink-policy.js, never written by the scanner' },
165
+ { name: 'privacy-governance.json', kind: 'file', classification: 'operator-config', note: 'FR-407 DPIA/RoPA governance field overrides (purpose, lawful basis, retention, etc.) — read by dataflow/privacy-governance.js, never written by the scanner' },
166
+ { name: 'compliance-severity-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-502 per-framework/default open-finding severity threshold override — read by posture/auditor-walkthrough.js, never written by the scanner' },
167
+ { name: 'authorized-approvers.json', kind: 'file', classification: 'operator-config', note: 'FR-1002 identity/role registry for high-impact fix approvals — read by fix/approver-registry.js, never written by the scanner' },
168
+ { name: 'policy-bundles', kind: 'dir', classification: 'operator-config', note: 'FR-1001 signed organization/repository/environment policy bundles (organization.json/repository.json/environment.json) — distributed by an org and placed by the operator, read by posture/policy-bundle.js, never written by the scanner' },
169
+ { name: 'policy-bundle-public-key.pem', kind: 'file', classification: 'operator-config', note: 'FR-1001 public key an operator installs to verify org-distributed policy bundles — read by posture/policy-bundle.js, never written by the scanner' },
170
+ { name: 'retention-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-702 per-retention-class TTL overrides (clamped to a built-in per-class maximum) — read by posture/retention-policy.js, never written by the scanner' },
171
+ { name: 'legal-holds.json', kind: 'file', classification: 'operator-config', note: 'FR-707 legal holds ({artifact, owner, reason, expires_at}) — read by posture/retention-policy.js and bin/agentic-security.js cmdReset; WRITTEN by the CLI (legal-hold add/remove), but classified operator-config (not generated) deliberately: a plain `reset` must never be able to delete the very record protecting other artifacts from deletion' },
172
+ { name: 'calibration-feedback.jsonl', kind: 'file', classification: 'operator-config', note: 'FR-806 opt-in calibration ground truth ({at, findingId, outcome: accept-risk|realized-incident, predicted*, note}) — WRITTEN by the CLI (calibration-feedback record), but classified operator-config like exploit-history.jsonl: real, hard-to-recreate customer-reported ground truth, never scanner-regenerable, so a routine reset must never delete it' },
173
+ { name: 'encryption-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-705 encryption provider/required opt-in policy ({provider: local-key, required: true|false}) — read by posture/encryption-provider.js, never written by the scanner' },
174
+ { name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
175
+ { name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
176
+ { name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
177
+ { name: 'cve-alerts.json', kind: 'file', classification: 'operator-config', note: 'own header comment: "Configuration is read from"; state lives in the separate cve-alerts-state.json, which IS generated' },
178
+ ];
179
+
180
+ export function listGeneratedArtifacts() {
181
+ return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated');
182
+ }
183
+
184
+ export function listOperatorConfigArtifacts() {
185
+ return ARTIFACT_REGISTRY.filter(a => a.classification === 'operator-config');
186
+ }
187
+
188
+ export function isRegisteredArtifact(name) {
189
+ return ARTIFACT_REGISTRY.some(a => a.name === name);
190
+ }
191
+
192
+ export function classificationOf(name) {
193
+ return ARTIFACT_REGISTRY.find(a => a.name === name)?.classification ?? null;
194
+ }
195
+
196
+ // FR-705: is this artifact marked as containing sensitive content an
197
+ // operator may want encrypted at rest? Deliberately a SEPARATE flag from
198
+ // classification/retentionClass — confidentiality is about content
199
+ // sensitivity, not about who writes it or how long it lives. See
200
+ // encryption-provider.js for the enforcement side (the fail-closed gate
201
+ // this flag feeds) and its own header for which artifacts are marked here
202
+ // in this first, deliberately conservative pass, and why others are not.
203
+ export function confidentialOf(name) {
204
+ return ARTIFACT_REGISTRY.find(a => a.name === name)?.confidential === true;
205
+ }
206
+
207
+ // FR-702: which retention class (if any) governs this artifact's TTL. Only
208
+ // 'generated' artifacts can carry one — an 'operator-config' entry is never
209
+ // auto-expired regardless of what this returns (retention-policy.js's own
210
+ // caller enforces that ordering, not this function).
211
+ export function retentionClassOf(name) {
212
+ return ARTIFACT_REGISTRY.find(a => a.name === name)?.retentionClass ?? null;
213
+ }
214
+
215
+ export function listArtifactsWithRetentionClass() {
216
+ return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated' && a.retentionClass);
217
+ }
@@ -27,14 +27,18 @@
27
27
  // User supplies their own control mapping in the same shape as the
28
28
  // bundled ones. The auditor-walkthrough renders evidence against it.
29
29
  //
30
- // Disclaimer: this module organizes scanner evidence into a narrative.
31
- // It does not certify compliance. A licensed assessor (CPA / auditor /
32
- // DPO) is responsible for the final attestation.
30
+ // Disclaimer: this module organizes scanner evidence into a narrative. It
31
+ // does not certify compliance. See evidence-grade-wording.js for why the
32
+ // emitted disclaimer names all three assurance tiers explicitly (this
33
+ // module's own is one of the ones that used to get the terminology
34
+ // backwards — "a licensed assessor is responsible for the final
35
+ // attestation" describes independent certification, not attestation).
33
36
 
34
37
  import * as fs from 'node:fs';
35
38
  import * as path from 'node:path';
36
39
 
37
40
  import { statePath, stateWritesEnabled } from './state-dir.js';
41
+ import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
38
42
  import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
39
43
  import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
40
44
 
@@ -187,7 +191,64 @@ export const COMPLIANCE_FAMILY_GAPS = {
187
191
  // works, which is the mirror image of the bug this prevents.
188
192
  };
189
193
 
194
+ // FR-501/FR-502 (assurance-hardening PRD, A-07): a `family:` mapping used to
195
+ // only count 'critical'/'high' findings as "open" — a control with 50 open
196
+ // MEDIUM findings on its mapped family rendered as
197
+ // "✓ no open critical/high findings", identical to a genuinely clean
198
+ // control. posture/privacy-framework.js already solved the analogous problem
199
+ // for its own four-bucket model (its header calls this out directly: a
200
+ // vacuous pass is "the same false assurance... arriving by a different
201
+ // route"); this raises the floor here to match rather than leaving two
202
+ // different standards for what counts as "open" across compliance surfaces.
203
+ // A named, ordered rank (not a hardcoded pair of string literals) so a
204
+ // future per-framework/per-policy threshold (FR-502's fuller scope) is a
205
+ // one-line change here rather than another hunt through the evaluator.
206
+ const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
207
+ const OPEN_FINDING_MIN_SEVERITY = 'medium';
208
+
209
+ // FR-502's fuller scope, delivered: "policy-specific rather than globally
210
+ // high/critical." An operator can lower (or raise) the open-finding floor
211
+ // per framework via .agentic-security/compliance-severity-policy.json:
212
+ // { "default": "medium", "byFramework": { "gdpr": "low" } }
213
+ // `default` overrides OPEN_FINDING_MIN_SEVERITY for every framework that
214
+ // has no more specific `byFramework` entry; a framework entry wins over
215
+ // `default`. Never inferred — an operator decision, same as
216
+ // dataflow/privacy-taxonomy.js's (FR-402) taxonomy customization and
217
+ // egress/policy.js's (FR-602) config-file precedent. A missing file, a
218
+ // malformed one, or a value that is not one of SEVERITY_RANK's five known
219
+ // keys all degrade to the built-in 'medium' floor — falling back to a rank
220
+ // of `undefined` would make the `>=` comparison always false, silently
221
+ // treating EVERY finding as "not open" (the exact vacuous-pass bug this
222
+ // threshold exists to prevent), so an invalid override must never reach
223
+ // the comparison at all.
224
+ const SEVERITY_POLICY_FILE = 'compliance-severity-policy.json';
225
+
226
+ function _resolveOpenFindingMinSeverity(scanRoot, frameworkId) {
227
+ if (!scanRoot) return OPEN_FINDING_MIN_SEVERITY;
228
+ let raw;
229
+ try {
230
+ raw = fs.readFileSync(statePath(scanRoot, SEVERITY_POLICY_FILE), 'utf8');
231
+ } catch {
232
+ return OPEN_FINDING_MIN_SEVERITY; // ENOENT (the common case) or any other read failure
233
+ }
234
+ let doc;
235
+ try {
236
+ doc = JSON.parse(raw);
237
+ } catch {
238
+ return OPEN_FINDING_MIN_SEVERITY; // malformed config — never throws, never blocks evaluation
239
+ }
240
+ if (!doc || typeof doc !== 'object') return OPEN_FINDING_MIN_SEVERITY;
241
+ const byFramework = (doc.byFramework && typeof doc.byFramework === 'object') ? doc.byFramework : {};
242
+ const candidate = (frameworkId && typeof byFramework[frameworkId] === 'string')
243
+ ? byFramework[frameworkId]
244
+ : (typeof doc.default === 'string' ? doc.default : null);
245
+ return (candidate && Object.prototype.hasOwnProperty.call(SEVERITY_RANK, candidate))
246
+ ? candidate
247
+ : OPEN_FINDING_MIN_SEVERITY;
248
+ }
249
+
190
250
  export function evaluateFramework(scanRoot, fw, scan) {
251
+ const minSeverity = _resolveOpenFindingMinSeverity(scanRoot, fw && fw.id);
191
252
  // CMP-2: last-scan.json (what this is actually handed in production) carries
192
253
  // findings across four separate channels — SAST (`findings`), secrets,
193
254
  // business-logic, and SCA (`supplyChain`) — because report/index.js's
@@ -291,12 +352,13 @@ export function evaluateFramework(scanRoot, fw, scan) {
291
352
  const candidates = resolveFamilyKeys(fam, families.keys())
292
353
  .flatMap(k => families.get(k) || []);
293
354
  const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
294
- const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
355
+ const minRank = SEVERITY_RANK[minSeverity];
356
+ const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
295
357
  if (open.length) {
296
358
  allCleared = false;
297
- obs.push(`${open.length} open ${fam} finding(s) at high/critical.`);
359
+ obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
298
360
  } else {
299
- obs.push(`✓ ${fam}: no open critical/high findings.`);
361
+ obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
300
362
  anyCleared = true;
301
363
  }
302
364
  anySignal = true;
@@ -420,7 +482,7 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
420
482
  lines.push(`> License: ${fw.license}`);
421
483
  if (fw.url) lines.push(`> Source: ${fw.url}`);
422
484
  lines.push('');
423
- lines.push('> **This walkthrough organizes scanner evidence into a narrative for an external auditor.** It does NOT certify compliance. A licensed assessor is responsible for the final attestation.');
485
+ lines.push(`> **This walkthrough organizes scanner evidence into a narrative for an external auditor.** ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
424
486
  lines.push('');
425
487
 
426
488
  const present = evaluation.filter(e => e.status === 'present').length;
@@ -475,4 +537,4 @@ export function persistWalkthrough(scanRoot, fw, body) {
475
537
  return fp;
476
538
  }
477
539
 
478
- export const _internals = { _readJson };
540
+ export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };
@@ -0,0 +1,201 @@
1
+ // FR-806 (assurance-hardening PRD): "Validate model calibration against
2
+ // accepted and realized incidents where customers opt in | Calibration
3
+ // reports are aggregated and privacy-preserving."
4
+ //
5
+ // SCOPE, stated explicitly because the PRD's own wording is terse: this
6
+ // codebase has no SaaS control plane and takes no runtime cloud calls
7
+ // (root CLAUDE.md's own "No runtime cloud calls" rule, and E10's own goal
8
+ // of "consistent policy... without requiring a SaaS control plane") — so
9
+ // "aggregated" here means aggregated WITHIN one installation, across every
10
+ // feedback event an operator has recorded over time, never aggregated
11
+ // ACROSS installations on some central server. An operator who wants a
12
+ // cross-organization rollup can feed this module's own report output into
13
+ // their own aggregation, the same way `fleet.js` composes many single-repo
14
+ // scans without a hosted backend.
15
+ //
16
+ // TWO OUTCOMES, matching the PRD's own two named cases:
17
+ // 'accepted-risk' — an operator/customer reviewed a finding, accepted
18
+ // the risk, and (later, of their own accord) reports
19
+ // that no incident occurred. A well-calibrated model
20
+ // should have predicted LOW confidence/risk for
21
+ // these.
22
+ // 'realized-incident' — an operator reports that a finding's
23
+ // vulnerability WAS actually exploited or otherwise
24
+ // caused a real incident. A well-calibrated model
25
+ // should have predicted HIGH confidence/risk for
26
+ // these — a realized incident on a LOW-predicted
27
+ // finding is exactly the miscalibration this
28
+ // requirement exists to surface.
29
+ //
30
+ // PRIVACY-PRESERVING AT THE SOURCE, not just at the report layer: a
31
+ // feedback record snapshots only the model's OWN prediction signals
32
+ // (confidence, severity, riskDollars.ev) plus the operator's outcome and
33
+ // optional free-text note — never file path, line, vuln title, or code
34
+ // snippet. This mirrors this codebase's existing privacy modules' own
35
+ // discipline (dataflow/privacy-*.js) of never persisting more than a
36
+ // report needs to answer its one question.
37
+ //
38
+ // OPT-IN, genuinely: nothing here is ever auto-populated by a scan. A
39
+ // record exists only when an operator explicitly calls
40
+ // `recordCalibrationFeedback` (via the CLI's `calibration-feedback record`
41
+ // command) — the file simply does not exist for every project that never
42
+ // opts in, and every read degrades to "no data" rather than throwing.
43
+
44
+ import * as fs from 'node:fs';
45
+ import * as crypto from 'node:crypto';
46
+ import { statePath, stateDir, isSafeStateDir, stateWritesEnabled } from './state-dir.js';
47
+
48
+ export const CALIBRATION_FEEDBACK_FILE = 'calibration-feedback.jsonl';
49
+ export const OUTCOMES = Object.freeze(['accepted-risk', 'realized-incident']);
50
+
51
+ // Below this many samples, a rate is an artifact of the sample, not a
52
+ // property of the model — same precedent as fix-metrics.js's RELIABLE_N.
53
+ const RELIABLE_N = 10;
54
+
55
+ function _findFinding(scanRoot, findingId) {
56
+ try {
57
+ const raw = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8'));
58
+ const findings = Array.isArray(raw.findings) ? raw.findings : [];
59
+ return findings.find(f => f && (f.id === findingId || f.stableId === findingId)) || null;
60
+ } catch { return null; }
61
+ }
62
+
63
+ // FR-806 privacy fix: the caller-supplied findingId is routinely a finding's
64
+ // plain `.id` (e.g. "client-side:DANGEROUS_INNERHTML:src/billing/secret.js:142"),
65
+ // which embeds the exact file path and line this module's own docstring
66
+ // promises never to persist. Never write the caller's raw string to disk:
67
+ // prefer the matched finding's own privacy-safe `.stableId` (a hash, by
68
+ // construction elsewhere in this codebase), and otherwise hash the input
69
+ // ourselves so a stale/removed finding's id can never leak path/line either.
70
+ function _privacySafeFindingId(findingId, finding) {
71
+ if (finding && typeof finding.stableId === 'string' && finding.stableId) return finding.stableId;
72
+ return crypto.createHash('sha256').update(findingId).digest('hex').slice(0, 16);
73
+ }
74
+
75
+ /**
76
+ * Record one opt-in calibration-feedback event. Snapshots ONLY the
77
+ * model's own prediction signals for the named finding (if it is still
78
+ * present in the last scan — a finding fixed/removed since is still
79
+ * recordable, just without a fresh snapshot) plus the outcome and an
80
+ * optional note. Never throws; returns {ok, record} or {ok:false, reason}.
81
+ */
82
+ export function recordCalibrationFeedback(scanRoot, { findingId, outcome, note } = {}) {
83
+ if (!findingId || typeof findingId !== 'string') return { ok: false, reason: '--finding-id is required' };
84
+ if (!OUTCOMES.includes(outcome)) return { ok: false, reason: `--outcome must be one of: ${OUTCOMES.join(', ')}` };
85
+ const finding = _findFinding(scanRoot, findingId);
86
+ const record = {
87
+ at: new Date().toISOString(),
88
+ findingId: _privacySafeFindingId(findingId, finding),
89
+ outcome,
90
+ predictedConfidence: finding && typeof finding.confidence === 'number' ? finding.confidence : null,
91
+ predictedConfidenceTier: finding?.confidenceTier || null,
92
+ predictedSeverity: finding?.severity || null,
93
+ predictedRiskEv: finding?.riskDollars && typeof finding.riskDollars.ev === 'number' ? finding.riskDollars.ev : null,
94
+ note: note ? String(note).slice(0, 280) : null,
95
+ };
96
+ // Append-only, same primitives as fix-metrics.js/triage-memory.js's own
97
+ // JSONL writers — a single fs.appendFileSync, never a read-modify-write
98
+ // of the whole file (which would also reintroduce a TOCTOU between an
99
+ // existence check and the write, the exact anti-pattern this codebase's
100
+ // own conventions forbid).
101
+ const dir = stateDir(scanRoot);
102
+ if (!isSafeStateDir(dir)) return { ok: false, reason: 'no safe state directory' };
103
+ if (!stateWritesEnabled()) return { ok: false, reason: 'state writes are disabled (--no-state)' };
104
+ try {
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ fs.appendFileSync(statePath(scanRoot, CALIBRATION_FEEDBACK_FILE), JSON.stringify(record) + '\n', 'utf8');
107
+ } catch (e) { return { ok: false, reason: e.message }; }
108
+ return { ok: true, record };
109
+ }
110
+
111
+ /**
112
+ * Every well-formed feedback event ever recorded. A line that fails to
113
+ * parse or lacks a valid outcome is skipped, not fatal.
114
+ */
115
+ export function loadCalibrationFeedback(scanRoot) {
116
+ let fp;
117
+ try { fp = statePath(scanRoot, CALIBRATION_FEEDBACK_FILE); } catch { return []; }
118
+ let raw;
119
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return []; }
120
+ const out = [];
121
+ for (const line of raw.split('\n')) {
122
+ if (!line.trim()) continue;
123
+ try {
124
+ const rec = JSON.parse(line);
125
+ if (rec && OUTCOMES.includes(rec.outcome)) out.push(rec);
126
+ } catch { /* torn or hand-edited line — drop it, keep the rest */ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function _avg(nums) {
132
+ const v = nums.filter(n => typeof n === 'number' && Number.isFinite(n));
133
+ return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
134
+ }
135
+
136
+ function _summarizeOutcome(records) {
137
+ const withConfidence = records.map(r => r.predictedConfidence).filter(c => typeof c === 'number');
138
+ return {
139
+ n: records.length,
140
+ reliable: records.length >= RELIABLE_N,
141
+ avgPredictedConfidence: _avg(withConfidence),
142
+ withoutPrediction: records.length - withConfidence.length,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Aggregate ALL recorded feedback (this installation only — see the
148
+ * module header for why cross-installation aggregation is out of scope)
149
+ * into a privacy-preserving report: rates and averages only, never a
150
+ * per-finding breakdown, never file/line/vuln text (none of that was ever
151
+ * stored in the first place).
152
+ */
153
+ export function buildCalibrationReport(scanRoot) {
154
+ const records = loadCalibrationFeedback(scanRoot);
155
+ const acceptedRisk = records.filter(r => r.outcome === 'accepted-risk');
156
+ const realizedIncident = records.filter(r => r.outcome === 'realized-incident');
157
+ return {
158
+ schema: 'agentic-security/calibration-report@1',
159
+ generatedAt: new Date().toISOString(),
160
+ totalEvents: records.length,
161
+ acceptedRisk: _summarizeOutcome(acceptedRisk),
162
+ realizedIncident: _summarizeOutcome(realizedIncident),
163
+ // The calibration question itself: accepted-risk events SHOULD skew
164
+ // toward low predicted confidence; realized-incident events SHOULD
165
+ // skew toward high. This flag is a coarse, disclosed-uncertainty
166
+ // signal, not a verdict — it only ever fires when BOTH buckets have
167
+ // enough samples to say anything at all (RELIABLE_N each).
168
+ possibleMiscalibration: (() => {
169
+ const a = _summarizeOutcome(acceptedRisk);
170
+ const r = _summarizeOutcome(realizedIncident);
171
+ if (!a.reliable || !r.reliable || a.avgPredictedConfidence == null || r.avgPredictedConfidence == null) return null;
172
+ return r.avgPredictedConfidence <= a.avgPredictedConfidence;
173
+ })(),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * One block of human-readable summary, or null when nothing has ever been
179
+ * recorded — genuinely opt-in, so "nothing recorded" is the expected
180
+ * default state for almost every project, not an error.
181
+ */
182
+ export function renderCalibrationReportSummary(report) {
183
+ if (!report || report.totalEvents === 0) return null;
184
+ const lines = [
185
+ 'Calibration feedback (this installation, opt-in):',
186
+ ` accepted-risk: n=${report.acceptedRisk.n}${report.acceptedRisk.reliable ? '' : ' (below reliable sample size)'}` +
187
+ (report.acceptedRisk.avgPredictedConfidence != null ? ` avg predicted confidence=${report.acceptedRisk.avgPredictedConfidence.toFixed(2)}` : ''),
188
+ ` realized-incident: n=${report.realizedIncident.n}${report.realizedIncident.reliable ? '' : ' (below reliable sample size)'}` +
189
+ (report.realizedIncident.avgPredictedConfidence != null ? ` avg predicted confidence=${report.realizedIncident.avgPredictedConfidence.toFixed(2)}` : ''),
190
+ ];
191
+ if (report.possibleMiscalibration === true) {
192
+ lines.push(' ⚠ realized incidents were NOT predicted with higher confidence than accepted risks — possible miscalibration.');
193
+ } else if (report.possibleMiscalibration === false) {
194
+ lines.push(' ✓ realized incidents were predicted with higher confidence than accepted risks, as expected.');
195
+ } else {
196
+ lines.push(' (not enough samples in both buckets yet to assess calibration — this is not a pass or fail.)');
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+
201
+ export const _internals = { RELIABLE_N, _findFinding };
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "_doc": "Seed calibration corpus for P1.3 / FR-UX-1. Each entry is (family, tp, fp) from running the engine against labeled benchmarks. The runtime merges this with the customer's .agentic-security/validator-metrics.json. Customer counts override when their n is higher.",
3
3
  "_source": "OWASP Benchmark v1.2 (Java) + Juliet Java + Juliet C/C++ + synthetic-bench fixtures + curated NodeGoat — counts as-of v0.48.0. ~30 samples per family minimum for calibrated emit.",
4
+ "_generatedAt": "2026-05-18T19:15:00-07:00",
5
+ "_generatedAtNote": "FR-207: the actual git commit timestamp of this file (git log -1 --format=%aI), recorded here as a machine-readable field so posture/calibration.js's calibrationFreshness() can compute a real age instead of inferring one from the '_source' version string. Update this value only when the family counts below actually change.",
4
6
  "_caveat": "This is a SEED corpus, not a held-out test set. The PRD G1 target (Brier ≤ 0.10) requires a separate held-out labeled set; that work is queued for Phase 5 finalization. Calibrated values shipped now are honest empirical TP rates from this seed, with their Wilson 95% CI and N visible so consumers can judge.",
5
7
  "families": {
6
8
  "sql-injection": { "tp": 41, "fp": 3 },
@@ -102,6 +102,31 @@ function _readJsonMaybe(fp) {
102
102
  // Load history from .agentic-security/validator-metrics.json + the bundled
103
103
  // seed file. The bundled seed ships with this release; the customer file
104
104
  // overrides per-family when N is higher there.
105
+ // FR-207: freshness of the seed calibration table itself. This is
106
+ // maintainer-authored data (not a per-customer opt-in like
107
+ // compliance-policy.js's `_staleness`), so the check is always-on against
108
+ // one fixed threshold rather than an opt-in interval — there is no
109
+ // owner/reviewer workflow for it to key off. `_generatedAt` missing (an
110
+ // old seed file predating this field) is treated the same as this
111
+ // codebase's other "never dated == already stale" cases, not a free pass.
112
+ const CALIBRATION_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;
113
+ let _calibrationFreshnessCache = null;
114
+ export function calibrationFreshness() {
115
+ if (_calibrationFreshnessCache) return _calibrationFreshnessCache;
116
+ const seedPath = new URL('./calibration-seed.json', import.meta.url);
117
+ let generatedAt = null;
118
+ try { generatedAt = JSON.parse(fs.readFileSync(seedPath, 'utf8'))._generatedAt || null; } catch { /* unreadable seed -> unknown, not fabricated */ }
119
+ const ts = generatedAt ? Date.parse(generatedAt) : NaN;
120
+ const baseline = Number.isFinite(ts) ? ts : 0;
121
+ const ageMs = Date.now() - baseline;
122
+ _calibrationFreshnessCache = {
123
+ generatedAt,
124
+ ageDays: Math.floor(ageMs / 86400000),
125
+ stale: ageMs > CALIBRATION_MAX_AGE_MS,
126
+ };
127
+ return _calibrationFreshnessCache;
128
+ }
129
+
105
130
  export function loadCalibrationHistory(scanRoot) {
106
131
  const customer = _readJsonMaybe(statePath(scanRoot, 'validator-metrics.json')) || {};
107
132
  const seedPath = new URL('./calibration-seed.json', import.meta.url);