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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
@@ -8,6 +8,8 @@
8
8
  //
9
9
  // nist-csf-2 NIST Cybersecurity Framework 2.0
10
10
  // nist-ai-600-1 NIST AI Risk Management Framework, GenAI profile
11
+ // nist-privacy-1-1 NIST Privacy Framework 1.1 (see posture/privacy-framework.js
12
+ // for the assessment + remediation layer over it)
11
13
  // owasp-asvs-5 OWASP Application Security Verification Standard 5.0
12
14
  // owasp-llm-top-10 OWASP Top 10 for LLM Applications 2025
13
15
  // eu-ai-act EU AI Act (Regulation 2024/1689)
@@ -32,9 +34,8 @@
32
34
  import * as fs from 'node:fs';
33
35
  import * as path from 'node:path';
34
36
 
37
+ import { statePath, stateWritesEnabled } from './state-dir.js';
35
38
  const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
36
- const STATE = '.agentic-security';
37
-
38
39
  function _readJson(fp) {
39
40
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
40
41
  }
@@ -52,7 +53,7 @@ export function listFrameworks(scanRoot) {
52
53
  }
53
54
  } catch {}
54
55
  if (scanRoot) {
55
- const projDir = path.join(scanRoot, STATE, 'compliance');
56
+ const projDir = statePath(scanRoot, 'compliance');
56
57
  if (fs.existsSync(projDir)) {
57
58
  try {
58
59
  for (const sub of fs.readdirSync(projDir)) {
@@ -73,7 +74,7 @@ export function listFrameworks(scanRoot) {
73
74
  */
74
75
  export function loadFramework(scanRoot, id) {
75
76
  if (scanRoot) {
76
- const projFp = path.join(scanRoot, STATE, 'compliance', id, 'controls.json');
77
+ const projFp = statePath(scanRoot, 'compliance', id, 'controls.json');
77
78
  if (fs.existsSync(projFp)) return _readJson(projFp);
78
79
  }
79
80
  for (const fn of fs.readdirSync(BUNDLED_DIR)) {
@@ -97,11 +98,69 @@ export function loadFramework(scanRoot, id) {
97
98
  * 'absent' — no signal / open critical findings on every mapsTo family
98
99
  * 'manual' — control has no mapsTo (requires manual attestation)
99
100
  */
101
+ // Stage 6 correctness audit: several compliance frameworks map controls to
102
+ // `family:auth-missing` / `family:authz`, but no detector in this codebase
103
+ // ever emits those literal family strings — the real missing-auth/authz
104
+ // detectors use `broken-access-control` (generic), `fastapi-missing-auth`,
105
+ // `springboot-missing-authz`, `laravel-missing-auth`, `quarkus-missing-authz`
106
+ // (framework-specific). Without this alias, a control mapped to
107
+ // auth-missing/authz read "present" (vacuously — the family bucket was
108
+ // always empty) even with a critical, unauthenticated route open. Listed
109
+ // under both compliance-side names since `broken-access-control` covers
110
+ // both "nobody checked" (missing auth) and "checked wrong" (broken authz)
111
+ // and it is strictly safer to over-count a real finding against both than
112
+ // to keep silently excluding it from either.
113
+ // CMP-1 (Stage 6 follow-up): grown incrementally by cross-referencing every
114
+ // `family:` string the bundled compliance-frameworks/*.json files reference
115
+ // against what src/sast + src/posture actually emit (a small, closed
116
+ // problem — only the strings a control actually maps to, not a universal
117
+ // vocabulary registry). k8s-admission.js's real rule id is
118
+ // `k8s-pod-privileged`; `nist-csf-2.json`/`hipaa-security-rule.json` map to
119
+ // the compliance-side spelling `k8s-pod-security-privileged`, which no
120
+ // detector ever emitted.
121
+ const COMPLIANCE_FAMILY_ALIAS = {
122
+ 'auth-missing': ['broken-access-control', 'fastapi-missing-auth', 'springboot-missing-authz', 'laravel-missing-auth', 'quarkus-missing-authz'],
123
+ 'authz': ['broken-access-control', 'idor', 'springboot-missing-authz', 'quarkus-missing-authz'],
124
+ 'k8s-pod-security-privileged': ['k8s-pod-privileged'],
125
+ };
126
+
127
+ // CMP-1 audit trail: every `family:` string referenced by the bundled
128
+ // compliance-frameworks/*.json files was cross-checked against real
129
+ // detector output; entries above are the confirmed naming mismatches with a
130
+ // real detector to alias, and `mcp-audit.js`/`sca/dep-confusion.js` were
131
+ // fixed at the SOURCE (they now set `family` explicitly) rather than
132
+ // aliased, since the finding constructors themselves were the root cause.
133
+ // Four references have no matching detector at all — not a naming
134
+ // mismatch, a genuine coverage gap that would need a new rule, out of
135
+ // scope for an alias table: `crypto-tls-version` (crypto-protocol.js checks
136
+ // verify-disabled, not minimum-TLS-version — the file's own header comment
137
+ // even names the never-implemented `crypto-tls-min-version` rule id),
138
+ // `nosql-injection` (referenced by cross-lang-meta.js's chain-detection
139
+ // list, but no dedicated NoSQL-injection detector exists), `pii-exposure`
140
+ // and `data-exposure` (both referenced only by consumers — threat-model-
141
+ // auto.js's classifier and a Juliet-benchmark answer-key label
142
+ // respectively — with no producer). Every control mapped to one of these
143
+ // four reads `manual`/`engine-gap` rather than a false `present`, which is
144
+ // the safe failure mode this whole mechanism exists to guarantee.
100
145
  export function evaluateFramework(scanRoot, fw, scan) {
146
+ // CMP-2: last-scan.json (what this is actually handed in production) carries
147
+ // findings across four separate channels — SAST (`findings`), secrets,
148
+ // business-logic, and SCA (`supplyChain`) — because report/index.js's
149
+ // normalizeFindings keeps them apart too. Reading only `scan.findings` made
150
+ // every secrets/logic/SCA finding invisible to every framework's family:
151
+ // mappings, e.g. a critical hardcoded secret never counted against a
152
+ // control mapped to family:hardcoded-secret. Each channel's family default
153
+ // mirrors normalizeFindings' own fallback for that channel so the two stay
154
+ // in agreement about what family an untagged finding belongs to.
101
155
  const findings = (scan && Array.isArray(scan.findings)) ? scan.findings : [];
156
+ const secrets = ((scan && Array.isArray(scan.secrets)) ? scan.secrets : [])
157
+ .map(s => ({ ...s, family: s.family || 'hardcoded-secret' }));
158
+ const logicVulns = (scan && Array.isArray(scan.logicVulns)) ? scan.logicVulns : [];
159
+ const supplyChain = ((scan && Array.isArray(scan.supplyChain)) ? scan.supplyChain : [])
160
+ .map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' }));
102
161
  const components = (scan && Array.isArray(scan.components)) ? scan.components : [];
103
162
  const families = new Map();
104
- for (const f of findings) {
163
+ for (const f of [...findings, ...secrets, ...logicVulns, ...supplyChain]) {
105
164
  const k = f.family || 'unknown';
106
165
  if (!families.has(k)) families.set(k, []);
107
166
  families.get(k).push(f);
@@ -121,15 +180,43 @@ export function evaluateFramework(scanRoot, fw, scan) {
121
180
 
122
181
  let allCleared = true;
123
182
  let anySignal = false;
183
+ // Tracks whether ANY family:/module: mapping in this control actually
184
+ // passed (distinct from allCleared, which asks whether EVERY one did).
185
+ // Feeds the 'absent' vs 'partial' distinction below: a control where
186
+ // nothing at all checks out is a materially different auditor story
187
+ // ("no evidence") than one that's mostly clean with one open gap
188
+ // ("evidence with a gap") — both used to render as the same 'partial'.
189
+ let anyCleared = false;
190
+ // CMP-2: a rule: mapping's own observation says "verify manually" — it
191
+ // is deliberately not code-checked. It used to set anySignal=true and
192
+ // leave allCleared untouched, so a control whose ONLY mapping was rule:
193
+ // resolved to 'present' (fully evidenced), the same status as a control
194
+ // with real, checked evidence. Any rule: mapping present caps the
195
+ // control at 'partial' — never 'present' — regardless of what the
196
+ // family:/module: mappings in the same control found.
197
+ let hasUnverifiableMapping = false;
124
198
  for (const m of maps) {
125
199
  if (m.startsWith('family:')) {
126
- const fam = m.slice('family:'.length).split(':')[0];
127
- const open = (families.get(fam) || []).filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
200
+ // `family:X` and the subfamily-qualified `family:X:Y` (used by
201
+ // owasp-llm-top-10 for LLM02/LLM06/LLM07) both used to collapse to
202
+ // just `X` here — the `:Y` qualifier was parsed and then silently
203
+ // dropped, so any finding of family X counted against every control
204
+ // mapped to X regardless of which subfamily the control actually
205
+ // named (four LLM controls flagged off one credential-in-prompt
206
+ // finding). Detectors that emit a subfamily always set it, so
207
+ // filtering is safe; findings with no subfamily set still count
208
+ // (recall-preserving default — same precedent as relevance.js).
209
+ const [fam, subfam] = m.slice('family:'.length).split(':');
210
+ const aliasFams = COMPLIANCE_FAMILY_ALIAS[fam] || [];
211
+ const candidates = [fam, ...aliasFams].flatMap(k => families.get(k) || []);
212
+ const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
213
+ const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
128
214
  if (open.length) {
129
215
  allCleared = false;
130
216
  obs.push(`${open.length} open ${fam} finding(s) at high/critical.`);
131
217
  } else {
132
218
  obs.push(`✓ ${fam}: no open critical/high findings.`);
219
+ anyCleared = true;
133
220
  }
134
221
  anySignal = true;
135
222
  } else if (m.startsWith('module:')) {
@@ -164,15 +251,16 @@ export function evaluateFramework(scanRoot, fw, scan) {
164
251
  // A '.../' sentinel marks a source-relative artifact (project source,
165
252
  // e.g. a hook or agent file) — resolve it against the scan root itself.
166
253
  // Everything else is a runtime artifact under the STATE dir. Without
167
- // this, `path.join(scanRoot, STATE, '.../x')` never resolves and the
254
+ // this, `statePath(scanRoot, '.../x')` never resolves and the
168
255
  // control falsely reads "not present" for every project.
169
256
  const resolved = !target ? null
170
257
  : target.startsWith('.../') ? path.join(scanRoot, target.slice(4))
171
- : path.join(scanRoot, STATE, target);
258
+ : statePath(scanRoot, target);
172
259
  const label = target ? target.replace(/^\.\.\.\//, '') : '(unmapped)';
173
260
  if (resolved && fs.existsSync(resolved)) {
174
261
  obs.push(`✓ ${mod}: ${label} present.`);
175
262
  anySignal = true;
263
+ anyCleared = true;
176
264
  } else {
177
265
  obs.push(`✗ ${mod}: expected ${label} not present.`);
178
266
  allCleared = false;
@@ -181,11 +269,23 @@ export function evaluateFramework(scanRoot, fw, scan) {
181
269
  // Could check whether a custom rule fires zero — leave a hint for now.
182
270
  obs.push(`(rule mapping) ${m} — verify manually that the bodyguard rule is enabled.`);
183
271
  anySignal = true;
272
+ hasUnverifiableMapping = true;
184
273
  }
185
274
  }
186
275
 
276
+ // 'absent' was documented (see the docstring above) as a fourth,
277
+ // distinct status — a control where NOTHING passed at all — but the
278
+ // assignment below never produced it; every non-present, non-manual
279
+ // control rendered as 'partial' regardless of whether it was "mostly
280
+ // clean with one gap" or "completely unevidenced". Only introduced for
281
+ // the fully-automated case (no rule: mapping) to avoid reclassifying
282
+ // any control that already reads 'partial' because of an inherently
283
+ // unverifiable rule: mapping — that precedent (rule: caps at 'partial',
284
+ // never reaching 'present' OR 'absent') is unchanged.
187
285
  if (!anySignal) status = 'manual';
286
+ else if (hasUnverifiableMapping) status = 'partial';
188
287
  else if (allCleared) status = 'present';
288
+ else if (!anyCleared) status = 'absent';
189
289
  else status = 'partial';
190
290
 
191
291
  results.push({ control: c, status, observations: obs });
@@ -251,7 +351,8 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
251
351
  * Persist the walkthrough at .agentic-security/auditor-walkthroughs/<id>.md
252
352
  */
253
353
  export function persistWalkthrough(scanRoot, fw, body) {
254
- const dir = path.join(scanRoot, STATE, 'auditor-walkthroughs');
354
+ const dir = statePath(scanRoot, 'auditor-walkthroughs');
355
+ if (!stateWritesEnabled()) return null;
255
356
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
256
357
  const fp = path.join(dir, `${fw.id}.md`);
257
358
  try { fs.writeFileSync(fp, body); } catch {}
@@ -27,6 +27,7 @@
27
27
  import * as fs from 'node:fs';
28
28
  import * as path from 'node:path';
29
29
 
30
+ import { statePath } from './state-dir.js';
30
31
  const KNOWN_GOOD = new Set([
31
32
  'session+csrf', 'session+admin', 'session+claim', 'session',
32
33
  'jwt+verify', 'oauth+pkce', 'mtls',
@@ -41,15 +42,15 @@ const FAMILIES_GATEABLE_BY_AUTH = new Set([
41
42
  ]);
42
43
 
43
44
  const CANDIDATE_PATHS = [
44
- '.agentic-security/auth-posture.json',
45
- '.agentic-security/auth-posture.yml',
46
- '.agentic-security/auth-posture.yaml',
45
+ 'auth-posture.json',
46
+ 'auth-posture.yml',
47
+ 'auth-posture.yaml',
47
48
  ];
48
49
 
49
50
  export function loadAuthPosture(scanRoot) {
50
51
  const root = scanRoot || process.cwd();
51
52
  for (const rel of CANDIDATE_PATHS) {
52
- const fp = path.join(root, rel);
53
+ const fp = statePath(root, rel);
53
54
  if (!fs.existsSync(fp)) continue;
54
55
  try {
55
56
  const text = fs.readFileSync(fp, 'utf8');
@@ -110,7 +110,14 @@ export async function runAutopilot({
110
110
  for (const f of inScope) {
111
111
  const key = f.stableId || `${f.file}:${f.line}:${f.vuln}`;
112
112
  const prior = resume ? state.findings[key] : null;
113
- if (prior?.outcome) { results.push(prior); continue; }
113
+ // A cached VERIFIED_FIXED whose patch was gated (not written) on a prior
114
+ // apply:false run is not a terminal outcome once the caller asks for
115
+ // apply:true — replaying it verbatim would silently never call
116
+ // applyFix on the exact two-step workflow (preview, then --apply) this
117
+ // gate exists to support. Everything else cached IS terminal and is
118
+ // replayed as before.
119
+ const gatedOnlyByApply = prior?.outcome === 'VERIFIED_FIXED' && prior.applied !== true && apply === true;
120
+ if (prior?.outcome && !gatedOnlyByApply) { results.push(prior); continue; }
114
121
 
115
122
  const rec = { key, file: f.file, line: f.line, vuln: f.vuln, severity: f.severity };
116
123
 
@@ -57,12 +57,18 @@ export function computeDrift(scanRoot, opts = {}) {
57
57
  const fb = loadTriageFeedback(scanRoot);
58
58
  if (!fb.length) return { alarms: [], note: 'no-feedback-data' };
59
59
 
60
- // Group by family. Each entry should carry: family, verdict ('tp'|'fp'|'wai'),
61
- // reportedConfidence (0..1), ts.
60
+ // Group by family. Each entry should carry: family, verdict ('tp'|'fp'|'wontfix'),
61
+ // reportedConfidence (0..1), at.
62
+ // Field names/verdict values here previously didn't match the real
63
+ // producer (commands/triage.md's Step 2): it writes `at`, not `ts`, and
64
+ // never wrote `reportedConfidence` at all (fixed there alongside this —
65
+ // Stage 2 measurement-completeness audit) or a `'wai'` verdict (it only
66
+ // ever writes 'tp'|'fp'|'wontfix'), so this alarm could never fire against
67
+ // real triage data regardless of how badly calibration had drifted.
62
68
  const byFamily = new Map();
63
69
  for (const e of fb) {
64
- if (!e || !e.family || !inWindow(e.ts, window)) continue;
65
- if (!['tp', 'fp', 'wai'].includes(e.verdict)) continue;
70
+ if (!e || !e.family || !inWindow(e.at, window)) continue;
71
+ if (!['tp', 'fp', 'wontfix'].includes(e.verdict)) continue;
66
72
  if (typeof e.reportedConfidence !== 'number') continue;
67
73
  if (!byFamily.has(e.family)) byFamily.set(e.family, []);
68
74
  byFamily.get(e.family).push(e);
@@ -75,7 +81,7 @@ export function computeDrift(scanRoot, opts = {}) {
75
81
  const reportedAcc = entries.reduce((acc, e) => acc + e.reportedConfidence, 0) / entries.length;
76
82
  const divergence = Math.abs(reportedAcc - realizedAcc);
77
83
  if (divergence < threshold) continue;
78
- const firstTs = entries.map(e => e.ts).filter(Boolean).sort()[0];
84
+ const firstTs = entries.map(e => e.at).filter(Boolean).sort()[0];
79
85
  alarms.push({
80
86
  alarm: true,
81
87
  since: firstTs,
@@ -121,15 +121,37 @@ export function loadCalibrationHistory(scanRoot) {
121
121
  };
122
122
  if (seed) merge(seed);
123
123
  if (customer) merge(customer);
124
- // Merge triage-derived TP/FP counts (auto-feedback loop)
124
+ // Merge triage-derived TP/FP counts (auto-feedback loop).
125
+ //
126
+ // EA-01: only a state a human actually chose is a real signal. triage.js's
127
+ // syncWithScan sets every new finding to 'open' with no human involved, and
128
+ // auto-closes any finding the scanner stops seeing to 'fixed' with
129
+ // `automatic:true` on the transition — neither reflects a judgment that the
130
+ // finding was real. Counting them as TP made the "true positive" column
131
+ // entirely machine-generated while the FP column required an explicit human
132
+ // false-positive mark, so calibrated confidence trended toward 1.0 for any
133
+ // family with enough scan volume regardless of actual accuracy. Only a
134
+ // manual 'fixed' transition (confirmed real, now resolved) counts as TP;
135
+ // only 'false-positive' counts as FP. 'open', 'in-progress', 'wont-fix',
136
+ // and an automatically-reached 'fixed' contribute to neither bucket.
125
137
  try {
126
138
  const triage = _readJsonMaybe(statePath(scanRoot, 'triage.json'));
127
139
  if (triage && triage.findings) {
140
+ const lastFixedIsAutomatic = new Map();
141
+ for (const t of triage.transitions || []) {
142
+ if (t && t.to === 'fixed') lastFixedIsAutomatic.set(t.id, t.automatic === true);
143
+ }
128
144
  const triageFams = {};
129
145
  for (const f of Object.values(triage.findings)) {
130
146
  const fam = f.family || 'unknown';
131
147
  if (!triageFams[fam]) triageFams[fam] = { tp: 0, fp: 0 };
132
- if (f.state === 'fixed' || f.state === 'open' || f.state === 'in-progress') triageFams[fam].tp++;
148
+ // Require POSITIVE proof of a manual transition (=== false), not
149
+ // merely the absence of proof of an automatic one (!== true) — the
150
+ // latter fails OPEN: a finding with no transition record at all
151
+ // (lastFixedIsAutomatic.get returns undefined) counted as a manual
152
+ // TP by default, exactly the machine-generated-signal failure mode
153
+ // this whole block's comment states it exists to prevent.
154
+ if (f.state === 'fixed' && lastFixedIsAutomatic.get(f.id) === false) triageFams[fam].tp++;
133
155
  else if (f.state === 'false-positive') triageFams[fam].fp++;
134
156
  }
135
157
  merge({ families: triageFams });