@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,208 @@
1
+ import * as fs from 'node:fs';
2
+ import * as fsp from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { statePath, stateWritesEnabled, isSafeStateDir } from '../state-dir.js';
5
+
6
+ // Both paths go through the state-dir seam rather than joining the state
7
+ // directory name by hand — see test/no-stray-state.test.js.
8
+ //
9
+ // What that does and does NOT buy: statePath() resolves WHERE state belongs
10
+ // (resolveProjectRoot walks up for a project marker), but it performs no
11
+ // safety check of its own. The marker check is `isSafeStateDir`, and it lives
12
+ // inside safeWriteState()/ensureStateDir() — which updateLifecycle
13
+ // deliberately bypasses (see its comment). So this module calls isSafeStateDir
14
+ // explicitly before writing; without that it would happily create
15
+ // `.agentic-security/` in a directory that is not a recognised project root,
16
+ // which is the litter state-dir.js exists to prevent.
17
+ function storePath(scanRoot) { return statePath(scanRoot, 'provenance', 'lifecycle.json'); }
18
+ function lockPath(scanRoot) { return statePath(scanRoot, 'provenance', 'lifecycle.lock'); }
19
+
20
+ export function readLifecycle(scanRoot) {
21
+ // Read directly and let the catch handle "missing" — an explicit
22
+ // existsSync() check first is a check-then-use race for no benefit, since
23
+ // the catch already covers every failure mode a stale check would too.
24
+ try {
25
+ return JSON.parse(fs.readFileSync(storePath(scanRoot), 'utf8'));
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ // Mirrors posture/fix-history.js's _withLogLock: an exclusive (wx) lockfile,
32
+ // released in finally{}. On contention (EEXIST), a stale lock — one whose
33
+ // holding PID is no longer alive, or one older than 30s — is reaped before
34
+ // falling through to the timeout-based retry loop, so a crashed/killed
35
+ // process (not just a throwing one) cannot wedge provenance updates forever.
36
+ async function withLock(scanRoot, fn) {
37
+ const lp = lockPath(scanRoot);
38
+ fs.mkdirSync(path.dirname(lp), { recursive: true });
39
+ const start = Date.now();
40
+ const TIMEOUT_MS = 5000;
41
+ while (true) {
42
+ try {
43
+ const handle = await fsp.open(lp, 'wx');
44
+ await handle.writeFile(String(process.pid));
45
+ try { await handle.close(); } catch {}
46
+ try {
47
+ return await fn();
48
+ } finally {
49
+ await fsp.unlink(lp).catch(() => {});
50
+ }
51
+ } catch (e) {
52
+ if (e && e.code === 'EEXIST') {
53
+ try {
54
+ const [st, pidStr] = await Promise.all([
55
+ fsp.stat(lp),
56
+ fsp.readFile(lp, 'utf8').catch(() => ''),
57
+ ]);
58
+ const pid = parseInt(pidStr.trim(), 10);
59
+ const pidAlive = Number.isFinite(pid) && isProcessAlive(pid);
60
+ const old = Date.now() - st.mtimeMs > 30000;
61
+ if (!pidAlive || old) {
62
+ try {
63
+ // Only unlink if the lockfile still holds the PID we just
64
+ // read, so we don't race the unlink against a fresh lock
65
+ // taken by another process in the meantime.
66
+ const recheck = (await fsp.readFile(lp, 'utf8').catch(() => '')).trim();
67
+ if (recheck === pidStr.trim()) {
68
+ await fsp.unlink(lp);
69
+ }
70
+ } catch {}
71
+ continue;
72
+ }
73
+ } catch {}
74
+ if (Date.now() - start > TIMEOUT_MS) throw new Error('provenance/lifecycle: lock timed out');
75
+ await new Promise((r) => setTimeout(r, 25));
76
+ continue;
77
+ }
78
+ throw e;
79
+ }
80
+ }
81
+ }
82
+
83
+ function isProcessAlive(pid) {
84
+ // POSIX: process.kill(pid, 0) probes existence without sending a signal.
85
+ // EPERM also means the process exists; only ESRCH means dead.
86
+ try { process.kill(pid, 0); return true; }
87
+ catch (e) { return e && e.code === 'EPERM'; }
88
+ }
89
+
90
+ function isOpenEvent(events) {
91
+ const last = events[events.length - 1];
92
+ return !!last && ['introduced', 'reintroduced', 'reverted', 'cherry-picked'].includes(last.type);
93
+ }
94
+
95
+ /**
96
+ * Fold this scan's findings into `store`, in memory. Pure with respect to the
97
+ * filesystem — extracted so the read-only path below can produce the SAME view
98
+ * the persisting path would, without writing anything.
99
+ *
100
+ * `completeScan` is the one structural guard in this module, and it exists
101
+ * because the two passes below make asymmetric claims:
102
+ *
103
+ * - The `introduced`/`reintroduced` pass reasons about findings that ARE
104
+ * present. Present is present regardless of how much of the tree was read,
105
+ * so it is sound on any scan and always runs.
106
+ * - The remediation pass reasons about findings that are ABSENT, turning
107
+ * absence into the positive claim "this was fixed." That is only sound if
108
+ * the scan actually looked everywhere it could have found them. On a subset
109
+ * scan (`--changed-since`, `--pr`, an MCP/LSP caller-supplied file list) the
110
+ * entire rest of the project is absent by construction, so running it marks
111
+ * every open finding outside the changed set remediated.
112
+ *
113
+ * Skipping the pass leaves those entries OPEN, which is the honest state: the
114
+ * scan has no evidence either way. A later complete scan closes whatever was
115
+ * genuinely fixed. Defaults true so an explicit `completeScan:false` is what
116
+ * suppresses it, never a caller forgetting to pass the flag.
117
+ */
118
+ function applyScan(store, currentFindings, { scanId, observedAt, completeScan = true }) {
119
+ const currentIds = new Set(currentFindings.map((f) => f.stableId).filter(Boolean));
120
+
121
+ for (const f of currentFindings) {
122
+ if (!f.stableId) continue;
123
+ const events = store[f.stableId] || (store[f.stableId] = []);
124
+ if (isOpenEvent(events)) continue;
125
+ const fp = f.findingProvenance;
126
+ const commit = fp?.findingOrigin?.commit || null;
127
+ const authorDate = fp?.status === 'complete' ? (fp.findingOrigin?.authorDate || observedAt) : observedAt;
128
+ // M3 §3.1: a reintroduction whose resolved findingOrigin is a genuine
129
+ // revert-of-a-fix or a cherry-picked propagation of an earlier
130
+ // introduction is a DIFFERENT lifecycle story than an unrelated
131
+ // reintroduction — both fields are only ever populated by deep-mode
132
+ // resolution (Task 3), so this vocabulary is silent (both null) for
133
+ // every standard-mode scan, which is the honest state: standard mode
134
+ // has no opinion on the distinction.
135
+ let type = events.length === 0 ? 'introduced' : 'reintroduced';
136
+ // `relatedCommit` carries the revert-target / cherry-pick-source SHA that
137
+ // was already read to CLASSIFY `type` above — without this, that SHA was
138
+ // discarded once the classification was made, so a 'reverted'/
139
+ // 'cherry-picked' event recorded THAT something was reverted/cherry-picked
140
+ // but not WHAT commit it was reverted/cherry-picked from, which is the
141
+ // fact a consumer actually needs to follow the link back.
142
+ let relatedCommit = null;
143
+ if (fp?.findingOrigin?.revertOf) { type = 'reverted'; relatedCommit = fp.findingOrigin.revertOf; }
144
+ else if (fp?.findingOrigin?.cherryPickOf) { type = 'cherry-picked'; relatedCommit = fp.findingOrigin.cherryPickOf; }
145
+ events.push({ type, commit, authorDate, scanId, observedAt, relatedCommit });
146
+ }
147
+
148
+ if (completeScan !== false) {
149
+ for (const [stableId, events] of Object.entries(store)) {
150
+ if (isOpenEvent(events) && !currentIds.has(stableId)) {
151
+ events.push({ type: 'remediated', commit: null, authorDate: null, scanId, observedAt });
152
+ }
153
+ }
154
+ }
155
+
156
+ return store;
157
+ }
158
+
159
+ export async function updateLifecycle(scanRoot, currentFindings, { scanId, observedAt, completeScan = true }) {
160
+ // Read-only scan (`--no-state` / AGENTIC_SECURITY_NO_STATE): return the view
161
+ // this scan WOULD have produced, computed in memory, and persist nothing.
162
+ //
163
+ // Returning the on-disk store unchanged would have been one line shorter and
164
+ // wrong in a quiet way — a caller asking "when was this finding introduced"
165
+ // would get "never" for every finding first seen in this scan, which is a
166
+ // false answer rather than a missing one. The lock is skipped too: a lockfile
167
+ // is itself a write into the scanned tree, and there is nothing to serialise
168
+ // when nothing is written.
169
+ if (!stateWritesEnabled()) {
170
+ return applyScan(readLifecycle(scanRoot), currentFindings, { scanId, observedAt, completeScan });
171
+ }
172
+
173
+ // The project-marker check safeWriteState() would have applied, applied here
174
+ // because the write below deliberately does not go through it.
175
+ //
176
+ // Checked BEFORE withLock, not inside it: withLock's first act is
177
+ // `fs.mkdirSync(path.dirname(lockPath))`, so guarding only the store write
178
+ // would still have created `.agentic-security/provenance/` in an
179
+ // unrecognised directory before refusing — the directory IS the litter, so
180
+ // refusing after creating it refuses nothing. Returns the same in-memory view
181
+ // the read-only path returns, for the same reason: a missing answer, not a
182
+ // false one.
183
+ if (!isSafeStateDir(path.dirname(storePath(scanRoot)))) {
184
+ return applyScan(readLifecycle(scanRoot), currentFindings, { scanId, observedAt, completeScan });
185
+ }
186
+
187
+ return withLock(scanRoot, async () => {
188
+ const store = applyScan(readLifecycle(scanRoot), currentFindings, { scanId, observedAt, completeScan });
189
+ // Deliberately a direct write, not safeWriteState(): this write is inside a
190
+ // locked critical section and its failure MUST propagate so the lock is
191
+ // released and the caller learns the store was not persisted.
192
+ // safeWriteState swallows errors and returns false, which would turn a
193
+ // failed write into a silent no-op that still looks like success.
194
+ fs.mkdirSync(path.dirname(storePath(scanRoot)), { recursive: true });
195
+ fs.writeFileSync(storePath(scanRoot), JSON.stringify(store, null, 2));
196
+ return store;
197
+ });
198
+ }
199
+
200
+ export function latestOpenIntroduction(store, stableId) {
201
+ const events = store[stableId];
202
+ if (!events || events.length === 0) return null;
203
+ const last = events[events.length - 1];
204
+ // Same open-type vocabulary as isOpenEvent: 'reverted'/'cherry-picked' are
205
+ // still open findings (M3 §3.1) — the classification is about HOW the
206
+ // finding became open, not whether it currently is.
207
+ return ['introduced', 'reintroduced', 'reverted', 'cherry-picked'].includes(last.type) ? last : null;
208
+ }
@@ -0,0 +1,137 @@
1
+ // Missing-control regression resolution (Finding Provenance PRD, M3 §3.3).
2
+ //
3
+ // Architecturally inverted from every other resolver in this directory:
4
+ // "when did a previously-observed safeguard DISAPPEAR," not "when did a bad
5
+ // pattern APPEAR." Walks backward from HEAD (newest-first — the opposite
6
+ // direction candidateCommitsForLine's oldest-first convention uses, because
7
+ // this resolver is searching for the MOST RECENT transition from present to
8
+ // absent, not the earliest transition from absent to present).
9
+ //
10
+ // The one invariant this module exists to enforce, verbatim from the spec:
11
+ // if the control is absent at EVERY reachable commit including the
12
+ // repository's own root, status is 'unknown' — NEVER attributed to the root
13
+ // commit. Every other resolver in this milestone treats "absent at the
14
+ // root, present now" as real evidence of introduction (the root IS the
15
+ // beginning of everything this repo can prove). This resolver's question is
16
+ // the mirror image — "when did it disappear" — and a control absent
17
+ // EVERYWHERE has no disappearance to date, which is a fundamentally
18
+ // different, weaker claim than "introduced at the beginning." Collapsing
19
+ // them would be exactly the false certainty the whole feature forbids.
20
+
21
+ import { getFirstParent, commitMeta, _relPath, _isSafeRevision } from './git-evidence.js';
22
+ import * as cp from 'node:child_process';
23
+ import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
24
+
25
+ const GIT_TIMEOUT_MS = 2000;
26
+ // Same hostile-repo hardening as git-evidence.js's `_run` (FR-PROV-024 /
27
+ // second audit). This module's own `_run` call is `log --format=%H
28
+ // --follow -- <path>` — no `-p`/`-L`, no diff/blob content rendered, so no
29
+ // `--no-textconv` surface to close here (see git-evidence.js for the calls
30
+ // where it is load-bearing).
31
+ function _run(scanRoot, args) {
32
+ try {
33
+ const stdout = cp.execFileSync('git', hardenGitArgs(args), {
34
+ cwd: scanRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS,
35
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024,
36
+ env: hardenGitEnv(),
37
+ });
38
+ return { ok: true, stdout };
39
+ } catch (e) {
40
+ return { ok: false, stdout: '', error: e };
41
+ }
42
+ }
43
+
44
+ // The commits touching `file` on the path from `since`/root to HEAD,
45
+ // NEWEST-FIRST (the reverse of git-evidence.js's candidateCommitsForFile,
46
+ // which is oldest-first — this resolver needs to walk backward from the
47
+ // present).
48
+ //
49
+ // This module reimplements its own git-invocation helper (`_run` above)
50
+ // rather than adding a new wrapper to git-evidence.js, but that must not
51
+ // mean skipping the argument-injection guards every OTHER resolver in this
52
+ // directory gets for free by routing through git-evidence.js. `since` feeds
53
+ // straight into a `<since>..HEAD` revision range and `file` becomes a bare
54
+ // argv token after `--` — both reuse git-evidence.js's own exported guards
55
+ // (`_isSafeRevision`, `_relPath`) rather than re-deriving the validation
56
+ // logic here, so a caller-supplied `since` shaped like a git flag (e.g.
57
+ // `--upload-pack=evil`) or a `file` that escapes scanRoot can never reach
58
+ // git's argv as an unvalidated token — same contract candidateCommitsForFile
59
+ // enforces for the forward-walking resolvers.
60
+ function candidateCommitsNewestFirst(scanRoot, file, since) {
61
+ const rel = _relPath(scanRoot, file);
62
+ if (!rel) return [];
63
+ if (since && !_isSafeRevision(since)) return [];
64
+ const args = ['log', '--format=%H', '--follow'];
65
+ if (since) args.push(`${since}..HEAD`);
66
+ args.push('--', rel);
67
+ const r = _run(scanRoot, args);
68
+ if (!r.ok) return [];
69
+ return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
70
+ }
71
+
72
+ export async function resolveMissingControl(scanRoot, { file, predicate, since, deadlineAt } = {}) {
73
+ if (!file || typeof predicate !== 'function') {
74
+ return { status: 'unknown', commitsConsidered: 0 };
75
+ }
76
+ const candidates = candidateCommitsNewestFirst(scanRoot, file, since);
77
+ if (candidates.length === 0) return { status: 'unknown', commitsConsidered: 0 };
78
+
79
+ let commitsConsidered = 0;
80
+ let priorPresentCommit = null; // the most-recently-checked commit where the control WAS present (walking backward, so this is the newest such commit seen so far)
81
+
82
+ for (const sha of candidates) {
83
+ if (deadlineAt && Date.now() > deadlineAt) return { status: 'budget_exhausted', commitsConsidered };
84
+ commitsConsidered++;
85
+ let presentHere;
86
+ try { presentHere = await predicate(scanRoot, sha, file); } catch { presentHere = false; }
87
+
88
+ if (presentHere) {
89
+ // Found a commit where it WAS present. If we've already seen a LATER
90
+ // (newer) commit where it was NOT present, that later commit's own
91
+ // transition (or the commit just after this one, going forward) is
92
+ // the removal — but walking newest-first, the removal transition is
93
+ // between THIS commit (present) and the previous iteration's commit
94
+ // (absent, checked earlier in this loop = newer in time). So: if
95
+ // priorPresentCommit is unset AND we've already walked past at least
96
+ // one absent commit, that means HEAD-side history is absent and THIS
97
+ // commit is present — the transition is between this commit and the
98
+ // one checked immediately before it in the loop.
99
+ if (commitsConsidered > 1 && priorPresentCommit === null) {
100
+ // The immediately-prior (newer) candidate was absent, and this one
101
+ // is present — that prior candidate (or the gap right after this
102
+ // commit) is where it disappeared. Report THIS commit (the last
103
+ // proven-present one) as the evidence anchor, and the prior
104
+ // candidate as the removal point.
105
+ const removedMeta = commitMeta(scanRoot, candidates[commitsConsidered - 2]);
106
+ const presentMeta = commitMeta(scanRoot, sha);
107
+ if (removedMeta && presentMeta) {
108
+ return {
109
+ status: 'complete',
110
+ removedAt: { commit: removedMeta.commit, authorName: removedMeta.authorName, authorDate: removedMeta.authorDate, summary: removedMeta.summary },
111
+ presentAt: { commit: presentMeta.commit, authorDate: presentMeta.authorDate },
112
+ commitsConsidered,
113
+ };
114
+ }
115
+ }
116
+ priorPresentCommit = sha;
117
+ // Present at the oldest commit we've checked so far and no removal
118
+ // found yet — keep walking older history in case there's an EARLIER
119
+ // removal-then-readd cycle; but for M3's scope (the Scenario I
120
+ // acceptance case is "never present, resolves unknown" and "present
121
+ // then removed, resolves complete"), stop here: control is present
122
+ // at this point in history and we have not yet found where it was
123
+ // removed relative to HEAD. Continue the loop.
124
+ }
125
+ // presentHere === false: keep walking older history (newest-first),
126
+ // looking for the presence that precedes this absence.
127
+ }
128
+
129
+ // Walked every candidate and never found a present→absent transition —
130
+ // either the control was NEVER present in any reachable commit (the
131
+ // Scenario I case — must resolve 'unknown', never attributed to the
132
+ // oldest candidate as if that were meaningful), or it has been present
133
+ // at every commit checked (no removal to report, which for THIS
134
+ // resolver's question — "when did it disappear" — is also 'unknown':
135
+ // there is no disappearance to date).
136
+ return { status: 'unknown', commitsConsidered };
137
+ }