@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5

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 (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -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} +2 -2
  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/{271.index.js → 2271.index.js} +2 -2
  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 +379 -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} +5 -5
  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} +5 -5
  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} +2 -2
  35. package/dist/5051.index.js +841 -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} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  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} +3 -3
  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} +560 -189
  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/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,337 @@
1
+ // remediation-ledger.js — M5 deliverable #6 (Blast-Radius: Remediation
2
+ // Command Center, FR-507 + AC-31), Task 2 of the sub-project: the IMPURE
3
+ // half. `../lineage/remediation.js` (Task 1) ships the pure RemediationItem
4
+ // contract — zero imports, no fs, never throws. This module owns every side
5
+ // effect that contract needs to become a real, durable, tamper-evident
6
+ // record: file locking, JSONL append, tolerant read, and the hash chain.
7
+ //
8
+ // This is the SECOND `posture/` → `lineage/` import in the codebase
9
+ // (`auditor-walkthrough.js`'s `graph:` branch was the first, per
10
+ // `posture/CLAUDE.md`'s "First `posture/` → `lineage/` import" section) —
11
+ // noted here so the boundary stays a deliberate, documented exception
12
+ // rather than an accident.
13
+ //
14
+ // ── The append-only contract (PRD line 984) ──────────────────────────────
15
+ //
16
+ // A remediation item's history is never rewritten, only appended to. Every
17
+ // decision — owner assignment, a state change, a manual attestation, an
18
+ // accepted-risk exception — is a permanent event, never an edit of a prior
19
+ // one. `foldRemediationItem`/`foldRemediationLedger` (Task 1) derive an
20
+ // item's CURRENT shape by replaying its events forward; nothing here ever
21
+ // mutates a written line.
22
+ //
23
+ // ── Why the physical shape is a JSONL append, not a whole-file rewrite ────
24
+ //
25
+ // `fix-metrics.js:69`'s `recordFixAttempt` is the precedent this module
26
+ // follows: "One writeSync of one newline-terminated line: a concurrent
27
+ // reader sees whole records or nothing, and a torn tail is dropped on
28
+ // read." `provenance/lifecycle.js:195`'s `updateLifecycle`, by contrast,
29
+ // reads the WHOLE store into memory, folds one scan's worth of changes in,
30
+ // and rewrites the WHOLE file — safe there because a lifecycle update is a
31
+ // single scan touching potentially every finding at once. A remediation
32
+ // ledger is the opposite shape: a human, one item at a time, arbitrarily
33
+ // interleaved with edits to every OTHER item. A partial rewrite of a shared
34
+ // document risks losing every OTHER item's history to a crash or a bug
35
+ // mid-rewrite; an append can only ever add a new, independently-readable
36
+ // line.
37
+ //
38
+ // ── Why a lock is still required, despite the append itself being atomic ──
39
+ //
40
+ // `fs.appendFileSync` of one line is atomic at the OS level, but writing an
41
+ // event is not just "append a line" — it is a READ-MODIFY-WRITE: the
42
+ // proposed event must be validated (`validateTransition`) against the
43
+ // item's CURRENT folded state, which requires reading and folding every
44
+ // prior event first. Two concurrent callers each reading the same "before"
45
+ // state and then both appending could both validate against a state that
46
+ // is stale by the time either write lands — e.g. two racing attempts to
47
+ // open the same item, both seeing "no item yet" and both succeeding, when
48
+ // exactly one must win. The lock serializes the whole
49
+ // read-fold-validate-append sequence into one critical section per event.
50
+ //
51
+ // `withLock` below is a faithful local PORT of
52
+ // `provenance/lifecycle.js:36`'s own `withLock` — NOT an import, because
53
+ // that function is not exported (verified directly against the file: it is
54
+ // a bare, module-private `async function withLock`). `isProcessAlive` is
55
+ // ported alongside it for the same reason.
56
+ //
57
+ // ── The hash chain ─────────────────────────────────────────────────────
58
+ //
59
+ // Byte-identical in behavior to `mcp/audit.js`'s own chain (`_sha`,
60
+ // `GENESIS`, `_readLastEntryHash`, `verifyAuditLog`, verified directly
61
+ // against that file before writing this one): each event carries `prev`,
62
+ // the SHA-256 hex digest of the PREVIOUS line's exact serialized JSON text
63
+ // (including that line's own `prev` field). The first event's `prev` is
64
+ // the literal string `'GENESIS'`. `readLedgerEvents`/`latestEventHash`
65
+ // walk forward from GENESIS; a line that fails to `JSON.parse` (a torn
66
+ // tail) or whose `prev` does not match the expected running hash (tamper)
67
+ // STOPS the walk. Both functions therefore return the longest verifying
68
+ // PREFIX of the file — never the full stream when any of it is
69
+ // unverifiable, and never a thrown error. See `_walkLedger` below, which
70
+ // both exported readers share so they cannot silently disagree on what
71
+ // "the last valid line" means.
72
+ //
73
+ // `appendLedgerEvent` is async and is the SINGLE place `validateTransition`
74
+ // is called in this codebase. No CLI command (Task 3) computes validity
75
+ // for itself — every proposed event is validated at this one write
76
+ // boundary, inside the lock, against the real current folded state. As of
77
+ // final-review fix round 1, this is also the single place THREE more
78
+ // things are enforced, all inside the same lock so none of them can race
79
+ // the write they guard: an `opened` event is additionally checked against
80
+ // `validateOpenPayload` (I4/M11 — previously only the CLI validated an
81
+ // `opened` payload's own shape, so a non-CLI caller could append a
82
+ // malformed one); the ledger's on-disk tail is checked for tearing before
83
+ // anything is appended onto it (I4 — appending onto a torn line would
84
+ // merge them into one unparseable line, silently losing the new event,
85
+ // and everything after it, forever); and an optional
86
+ // `opts.expectedBaseHash` optimistic-concurrency check runs against the
87
+ // real `lastHash` computed inside the lock (I5 — previously the CLI's own
88
+ // `--base-event` guard ran OUTSIDE the lock, a real TOCTOU: another
89
+ // process could append in the window between that check and this
90
+ // function's own lock acquisition).
91
+
92
+ import * as fs from 'node:fs';
93
+ import * as fsp from 'node:fs/promises';
94
+ import * as path from 'node:path';
95
+ import * as crypto from 'node:crypto';
96
+ import { statePath, isSafeStateDir, stateWritesEnabled } from './state-dir.js';
97
+ import { validateTransition, validateOpenPayload, foldRemediationLedger } from '../lineage/remediation.js';
98
+
99
+ const GENESIS = 'GENESIS';
100
+
101
+ function _sha(s) {
102
+ return crypto.createHash('sha256').update(s).digest('hex');
103
+ }
104
+
105
+ export function ledgerPaths(scanRoot) {
106
+ return {
107
+ ledgerPath: statePath(scanRoot, 'remediation', 'items.jsonl'),
108
+ lockPath: statePath(scanRoot, 'remediation', 'items.lock'),
109
+ };
110
+ }
111
+
112
+ // Shared walk: reads the ledger file (missing → empty), splits on '\n',
113
+ // drops empty lines, and walks forward maintaining `expectedPrev` (starting
114
+ // at GENESIS) exactly as `mcp/audit.js`'s `verifyAuditLog` does. A parse
115
+ // failure or a `prev` mismatch stops the walk without throwing. Returns
116
+ // `{events, lastHash}` so `readLedgerEvents`/`latestEventHash` cannot drift
117
+ // apart on what "the last valid line" means.
118
+ function _walkLedger(scanRoot) {
119
+ const { ledgerPath } = ledgerPaths(scanRoot);
120
+ let raw;
121
+ try {
122
+ raw = fs.readFileSync(ledgerPath, 'utf8');
123
+ } catch {
124
+ return { events: [], lastHash: GENESIS };
125
+ }
126
+ const lines = raw.split('\n').filter(Boolean);
127
+ const events = [];
128
+ let expectedPrev = GENESIS;
129
+ let lastHash = GENESIS;
130
+ for (const line of lines) {
131
+ let event;
132
+ try {
133
+ event = JSON.parse(line);
134
+ } catch {
135
+ break; // torn tail — stop, do not throw
136
+ }
137
+ if (!event || typeof event !== 'object' || Array.isArray(event) || event.prev !== expectedPrev) {
138
+ break; // tamper detected — stop, do not throw
139
+ }
140
+ events.push(event);
141
+ lastHash = _sha(line);
142
+ expectedPrev = lastHash;
143
+ }
144
+ return { events, lastHash };
145
+ }
146
+
147
+ // Returns the longest verifying PREFIX of the ledger — never the full
148
+ // stream when a tail is torn or a middle line is tampered. Never throws.
149
+ export function readLedgerEvents(scanRoot) {
150
+ return _walkLedger(scanRoot).events;
151
+ }
152
+
153
+ // GENESIS when the ledger is empty/missing, or when nothing in it verifies.
154
+ export function latestEventHash(scanRoot) {
155
+ return _walkLedger(scanRoot).lastHash;
156
+ }
157
+
158
+ // Reports whether the ledger's real content on disk has more raw lines than
159
+ // the longest verifying prefix — i.e. a torn tail OR a tampered middle line
160
+ // broke the hash chain partway through. Never throws. This is a read-only
161
+ // diagnostic; it does not change what readLedgerEvents/latestEventHash
162
+ // return (both still return the longest verifying prefix, unconditionally
163
+ // safe by construction) — it exists so a caller (the CLI's `list` command)
164
+ // can surface a loud warning instead of silently presenting a shorter or
165
+ // stale history as if it were the whole truth. (I7, final-review fix
166
+ // round 1.)
167
+ export function ledgerIntegrity(scanRoot) {
168
+ const { ledgerPath } = ledgerPaths(scanRoot);
169
+ let raw;
170
+ try {
171
+ raw = fs.readFileSync(ledgerPath, 'utf8');
172
+ } catch {
173
+ return { ok: true, totalLines: 0, verifiedLines: 0 };
174
+ }
175
+ const totalLines = raw.split('\n').filter(Boolean).length;
176
+ const { events } = _walkLedger(scanRoot);
177
+ return { ok: events.length === totalLines, totalLines, verifiedLines: events.length };
178
+ }
179
+
180
+ function isProcessAlive(pid) {
181
+ // POSIX: process.kill(pid, 0) probes existence without sending a signal.
182
+ // EPERM also means the process exists; only ESRCH means dead.
183
+ try { process.kill(pid, 0); return true; }
184
+ catch (e) { return e && e.code === 'EPERM'; }
185
+ }
186
+
187
+ // Faithful local port of `provenance/lifecycle.js:36-88`'s `withLock` — not
188
+ // an import, since that function is module-private there. Exclusive `wx`
189
+ // open of the lockfile, write the PID, run `fn` in a `try`, `unlink` in
190
+ // `finally`. On EEXIST, a stale lock (holding PID not alive, or lockfile
191
+ // older than 30s) is reaped, re-reading the lockfile before unlinking so a
192
+ // fresh holder taken by another process in the meantime is never raced.
193
+ // 25ms retry; 5s timeout throwing a named error.
194
+ async function withLock(lockPath, fn) {
195
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
196
+ const start = Date.now();
197
+ const TIMEOUT_MS = 5000;
198
+ while (true) {
199
+ try {
200
+ const handle = await fsp.open(lockPath, 'wx');
201
+ await handle.writeFile(String(process.pid));
202
+ try { await handle.close(); } catch {}
203
+ try {
204
+ return await fn();
205
+ } finally {
206
+ await fsp.unlink(lockPath).catch(() => {});
207
+ }
208
+ } catch (e) {
209
+ if (e && e.code === 'EEXIST') {
210
+ try {
211
+ const [st, pidStr] = await Promise.all([
212
+ fsp.stat(lockPath),
213
+ fsp.readFile(lockPath, 'utf8').catch(() => ''),
214
+ ]);
215
+ const pid = parseInt(pidStr.trim(), 10);
216
+ const pidAlive = Number.isFinite(pid) && isProcessAlive(pid);
217
+ const old = Date.now() - st.mtimeMs > 30000;
218
+ if (!pidAlive || old) {
219
+ try {
220
+ // Only unlink if the lockfile still holds the PID we just
221
+ // read, so we don't race the unlink against a fresh lock
222
+ // taken by another process in the meantime.
223
+ const recheck = (await fsp.readFile(lockPath, 'utf8').catch(() => '')).trim();
224
+ if (recheck === pidStr.trim()) {
225
+ await fsp.unlink(lockPath);
226
+ }
227
+ } catch {}
228
+ continue;
229
+ }
230
+ } catch {}
231
+ if (Date.now() - start > TIMEOUT_MS) throw new Error('remediation-ledger: lock timed out');
232
+ await new Promise((r) => setTimeout(r, 25));
233
+ continue;
234
+ }
235
+ throw e;
236
+ }
237
+ }
238
+ }
239
+
240
+ // Resolves the itemId a proposed event refers to, mirroring
241
+ // `foldRemediationLedger`'s own grouping fallback exactly (Task 1's
242
+ // `../lineage/remediation.js`: `ev.itemId ?? (ev.type === 'opened' ?
243
+ // ev.id : undefined)`) — an `opened` event carries `id` (per
244
+ // `foldRemediationItem`'s own use of `openEvent.id`), every other event
245
+ // type carries `itemId`. Reusing the identical fallback here, rather than
246
+ // reading `eventPayload.itemId` alone, is what makes item resolution agree
247
+ // with how the ledger will actually be folded on read — an opened event
248
+ // for an id that already exists must resolve to that existing item, not
249
+ // silently miss it because the payload only carries `id`.
250
+ function _resolveItemId(eventPayload) {
251
+ if (!eventPayload || typeof eventPayload !== 'object') return undefined;
252
+ return eventPayload.itemId ?? (eventPayload.type === 'opened' ? eventPayload.id : undefined);
253
+ }
254
+
255
+ // This function is the single place `validateTransition` is called. No CLI
256
+ // command computes validity for itself. `opts.expectedBaseHash` (I5) is an
257
+ // optional optimistic-concurrency check, compared against the real
258
+ // `lastHash` computed INSIDE the lock — the authoritative half of the
259
+ // `--base-event` guard; `undefined` (the flag was never passed) performs no
260
+ // check.
261
+ export async function appendLedgerEvent(scanRoot, eventPayload, opts = {}) {
262
+ const { ledgerPath, lockPath } = ledgerPaths(scanRoot);
263
+ const dir = path.dirname(ledgerPath);
264
+
265
+ // Refused BEFORE the lock is taken, so an unsafe/disabled target never
266
+ // even gets a lockfile written into it.
267
+ if (!isSafeStateDir(dir)) {
268
+ return {
269
+ valid: false,
270
+ errors: [{ field: '(scanRoot)', message: 'refusing to write — not a recognized project state directory' }],
271
+ };
272
+ }
273
+ if (!stateWritesEnabled()) {
274
+ return {
275
+ valid: false,
276
+ errors: [{ field: '(state)', message: 'state writes are disabled (AGENTIC_SECURITY_NO_STATE or setStateWritesEnabled(false))' }],
277
+ };
278
+ }
279
+
280
+ return withLock(lockPath, async () => {
281
+ // I4 (final-review fix round 1): refuse to append onto a torn tail —
282
+ // concatenating a new event onto an unterminated final line would
283
+ // merge them into one unparseable line, silently losing this event
284
+ // (and everything after it) forever while still reporting success. A
285
+ // crash/ENOSPC mid-write leaves exactly this shape. Checked first,
286
+ // inside the lock, before anything else touches the file.
287
+ try {
288
+ const raw = fs.readFileSync(ledgerPath, 'utf8');
289
+ if (raw.length > 0 && !raw.endsWith('\n')) {
290
+ return {
291
+ valid: false,
292
+ errors: [{ field: '(ledger)', message: 'the ledger file has a torn/unterminated final line — refusing to append onto it. Recover the file (restore from backup, or manually truncate to its last complete, newline-terminated line) before retrying.' }],
293
+ };
294
+ }
295
+ } catch { /* missing file — nothing to check */ }
296
+
297
+ const { events, lastHash } = _walkLedger(scanRoot);
298
+
299
+ // I5 (final-review fix round 1): the authoritative optimistic-
300
+ // concurrency check, run against the real `lastHash` computed inside
301
+ // this same critical section — the CLI's own pre-lock check is still
302
+ // useful as a cheap, early fail, but this is the one that cannot be
303
+ // raced by a concurrent writer.
304
+ if (opts.expectedBaseHash !== undefined && opts.expectedBaseHash !== lastHash) {
305
+ return {
306
+ valid: false,
307
+ errors: [{ field: '(base-event)', message: 'the ledger changed since --base-event was computed (a concurrent write) — refusing to append.' }],
308
+ };
309
+ }
310
+
311
+ const items = foldRemediationLedger(events);
312
+ const itemId = _resolveItemId(eventPayload);
313
+ const item = itemId != null ? (items[itemId] ?? null) : null;
314
+
315
+ // M11: an `opened` event's own shape is validated here too, not just
316
+ // by the CLI — mirrors "the single place validity is enforced" for
317
+ // the one event type `validateTransition` deliberately does not
318
+ // shape-check (it only checks that no item with this id exists yet).
319
+ if (eventPayload && eventPayload.type === 'opened') {
320
+ const openCheck = validateOpenPayload(eventPayload);
321
+ if (!openCheck.valid) {
322
+ return { valid: false, errors: openCheck.errors };
323
+ }
324
+ }
325
+
326
+ const { valid, errors } = validateTransition(item, eventPayload);
327
+ if (!valid) {
328
+ return { valid: false, errors };
329
+ }
330
+
331
+ const event = { ...eventPayload, prev: lastHash };
332
+ const line = JSON.stringify(event);
333
+ fs.mkdirSync(dir, { recursive: true });
334
+ fs.appendFileSync(ledgerPath, line + '\n', 'utf8');
335
+ return { valid: true, errors: [], event, hash: _sha(line) };
336
+ });
337
+ }
@@ -0,0 +1,47 @@
1
+ # scanner/src/server/
2
+
3
+ The `agentic-security explore` local server — Milestone 3 of the Data
4
+ Flow Explorer PRD ("local server and secure API," PRD §17). Read-only in
5
+ this increment; serves the already-scanned, already-signed
6
+ `DataFlowGraph v1` artifact over a hardened loopback HTTP API.
7
+
8
+ ## What's here
9
+
10
+ **Sub-project Server, increment 1 (read-only GET endpoints + full
11
+ security posture) — COMPLETE:**
12
+
13
+ | Module | Responsibility |
14
+ |---|---|
15
+ | `docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-server-scoping.md` + `…-s1-plan.md` | Confirms the entire deliverable was greenfield (no HTTP server code anywhere; the MCP server is JSON-RPC-over-stdio, the LSP server a different stdio protocol — neither has an HTTP surface). Scopes S1 to the five P0 GET endpoints only, deferring `query`/`export` (real design work of their own) to S2/M4. Every §17.4 security bullet is a first-class S1 requirement, not a follow-up pass, since `docs/DATA_FLOW_EXPLORER_THREAT_MODEL.md`'s own T2 (DNS rebinding)/T3 (CSRF)/T4 (path traversal) entries are explicitly gated on "the server does not exist yet." |
16
+ | `graph-loader.js` | `loadSignedGraph(scanRoot)` — reads `.agentic-security/lineage-graph.json` + `.sig`, reusing `posture/integrity.js`'s `verifyLastScan` DIRECTLY (never a second, parallel verification mechanism — that function already uses `crypto.timingSafeEqual` internally). Four distinct, clearly-messaged failure reasons: `missing` (no file), `unsigned` (`.sig` absent), `tampered` (signature mismatch), `malformed` (valid signature, invalid JSON). Loaded once at server startup, held in memory — this is a read-only, single-scan-snapshot server; a change to the graph on disk mid-session is out of scope (the threat model doc's own "P0 is read-only" framing). |
17
+ | `security.js` | `generateSessionToken()` (32 random bytes, hex); `constantTimeEqual(a, b)` (wraps `crypto.timingSafeEqual`, length-guarded the same way `verifyLastScan` guards its own HMAC comparison — never throws on mismatched-length input); `isValidHost(hostHeader, expectedPort)` — the T2 DNS-rebinding defense, accepting only `127.0.0.1`/`localhost`/`::1` (bare or bracketed) at the exact expected port, rejecting everything else including alternate-encoded loopback forms (decimal/octal/hex IP literals) — a hostile page's own Host header carries ITS OWN origin, never `127.0.0.1`, so this check defeats DNS rebinding regardless of browser same-origin-policy quirks; `CSP_HEADER_VALUE = "default-src 'none'; frame-ancestors 'none'"` (maximally restrictive — this server serves JSON only in S1, no HTML/inline script surface at all). |
18
+ | `routes.js` | Six PURE handler functions, each `(graph, ...) -> {status, body}` — `handleScan`, `handleGraph`, `handleNode`, `handleEdge`, `handleFlow`, `handleQuery` — no `req`/`res` access anywhere in this file, independently unit-testable without an HTTP layer. `wrapResponse(data, graph, {canonicalIds})` wraps every body in the exact envelope PRD line 1326 requires ("base graph/snapshot digest, schema/extension versions, scope, coverage, limitations, and contributing canonical IDs"), mapped onto the graph's own real fields (`graphId`→digest, `schemaVersion`, `extensions`, `scope`, `coverage`, `limitations`). `canonicalIds` is `null` for `handleScan`/`handleGraph`/`handleQuery` (all three describe the whole graph or a filtered projection of it — `handleGraph`'s own body already IS the full node/edge/flow arrays, so re-listing every id would be pure duplication with real cost on a large graph), `[id]` for `handleNode`/`handleEdge`, and the flow's own id plus its `source`/`sink`/`edgeIds` for `handleFlow` (a derived record referencing several other entities — naming them is genuinely useful metadata, not duplication). **`handleQuery(graph, filter)`** (Milestone 5, large-graph pagination — the `POST /api/v1/query` endpoint the S1-era header comment above and this file's own "What this does NOT do" section used to defer) shape-validates `filter` via `export-json.js`'s `validateFilterShape` (400 on a malformed filter, never a thrown exception) and, when valid, narrows the graph via that same module's already-shipped, already-tested `_filterGraph` — the identical `{nodeIds, edgeIds}` shape `dataflow export --filter` and the MCP `dataflow_get_graph` tool already use, reused rather than reimplemented. `undefined`/`{}` returns the whole graph, identical to `handleGraph`. |
19
+ | `http-server.js` | `createExploreServer({graph, port, sessionToken, idleTimeoutMs, keepOpen})` — the first `node:http` usage anywhere in `scanner/src/`. Binds explicitly to `127.0.0.1` (never `0.0.0.0`, never an omitted host argument — that is NOT the same as `127.0.0.1` on every platform). Every request: `Host` header validated (T2, 400 on failure) BEFORE the session token (T3, 401 on failure, `constantTimeEqual`) BEFORE a uniform request-size cap (`MAX_REQUEST_BODY_BYTES = 64KB`, 413 on excess — S1's GET endpoints have no meaningful body; Milestone 5's `POST /api/v1/query` is the first real consumer of the body-accumulation this cap now gates) BEFORE routing. `POST /api/v1/query`'s request body is accumulated alongside the pre-existing size-cap check and JSON-parsed only for a matched route with a non-empty body (malformed JSON → 400, never a 500) — every pre-M5 GET route still ignores the handler's 3rd `body` argument entirely, so an empty/missing body for them stays a no-op, unchanged. Every response: `Content-Security-Policy` set, `Cache-Control: no-store` set, `Access-Control-Allow-Origin` NEVER set anywhere in this file (CORS stays disabled by the header's own absence — do not add a permissive default later without re-reading this line). Idle-timeout auto-stop (`DEFAULT_IDLE_TIMEOUT_MS = 30 min`, no PRD-specified number exists, a disclosed reasoned default) resets ONLY on requests that pass BOTH the Host and token checks — an unauthenticated prober (a DNS-rebinding attempt, a stray scan) can never keep the server alive by hammering it; only genuine authenticated activity extends the session. `keepOpen: true` suppresses auto-stop entirely. Logging is metadata-only (method/path/status/duration) — never response bodies, never the session token, even on a failed-auth attempt. A 500 from a route handler returns a generic `{error: 'internal error'}` body, never the underlying exception's message/stack. **Disclosed, non-security gap**: no `SIGINT`/`SIGTERM` handler exists yet, so Ctrl-C relies on Node's default abrupt-exit rather than a graceful `server.close()` — functionally fine (the process does exit) but not maximally graceful; a future increment could add one for a cleaner shutdown message. |
20
+ | `bin/agentic-security.js` (extended, additively) | `case 'explore':` + `cmdExplore(args)` — parses the scan root (`args._[1]`, default cwd), `--port` (validated: digits-only, ≤65535), `--keep-open`. Calls `loadSignedGraph` FIRST; on any failure, prints the specific clear message and exits non-zero WITHOUT ever starting the server (never binds a port, never generates a token, on a missing/tampered/unsigned/malformed graph). On success, generates a fresh session token per invocation and prints it to stdout EXACTLY ONCE — the only place it is ever displayed, never written to a file, never logged again after this one print. Returns a Promise resolving to exit code 0 when the server eventually closes (idle-timeout or an external kill), matching every other `cmdX(args)` function's own "returns a numeric exit code" contract while still supporting a long-running process. `explore` never triggers a scan itself — it requires a pre-existing artifact, matching §17.3's own "P0 is read-only with respect to source" framing exactly. |
21
+ | `test/server/*.test.js` (41 tests) | `security.test.js`/`graph-loader.test.js`/`routes.test.js` — pure-function unit tests (isolated `constantTimeEqual`/`isValidHost` edge cases; all four `loadSignedGraph` failure reasons via a real signed/tampered/unsigned/malformed fixture; all five route handlers' shapes). `http-server.test.js` — REAL end-to-end `node:http.request` calls against a REAL running server (never a mocked/isolated unit test for the security-critical properties): a forged `Host` header rejected live (T2); a missing AND a wrong session token rejected live on every one of the five endpoints (T3); response headers asserted present/absent on BOTH a 200 and a rejected response; a real short-idle-timeout variant proving the server genuinely stops listening and refuses new connections (`ECONNREFUSED`), and a `keepOpen: true` variant proving it doesn't; an oversized-body 413. `cmd-explore.test.js` — the real spawned CLI end to end (missing graph → non-zero exit, no token ever printed, connection refused; a valid signed graph → a real server whose printed URL + token authenticate a real request). Independently re-verified by the coordinator via a THIRD path beyond the implementer's own tests: a real `explore` invocation launched manually, hit with `curl` from outside any test harness, confirming the same 200/401/400 outcomes and headers live. |
22
+
23
+ **Sub-project Wire (live-API wiring for the frontend) — COMPLETE:**
24
+
25
+ | Module | Responsibility |
26
+ |---|---|
27
+ | `docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-wire-scoping.md` + `…-plan.md` | The load-bearing correction this increment starts from: §17.4's CORS-disabled-by-default posture means the frontend MUST be served from the SAME origin as the API, or the browser blocks every `fetch()` regardless of what the server returns. Decides the session token travels via a URL FRAGMENT (`#token=...`), never a query string or cookie — a fragment is never sent to the server in any HTTP request. |
28
+ | `static-assets.js` | `FRONTEND_ROOT` (located via the exact `path.dirname(fileURLToPath(import.meta.url))` pattern `mcp/server.js` already established, resolved to the real `frontend/` directory). `resolveStaticAsset(requestPath)` — a PURE function, no fs/http access — resolving a request pathname against an explicit ALLOWLIST (`index.html`; `src/**/*.js`; top-level `styles/*.css` only, no nested subdirs) and returning `{ok:true, relativePath, contentType}` or `{ok:false, reason}`. This is a real allowlist, not merely a `../`-traversal guard: `test/`, `scripts/`, `package.json`, `README.md`, `CLAUDE.md`, `.gitignore` are all real files that exist on disk under `frontend/` and are REJECTED (`reason: 'not-allowlisted'`) even though a naive "stays inside frontend/" check would happily serve them. Traversal defense re-checks AFTER `path.posix.normalize` (never trusts normalization alone) for both literal and percent-encoded (`%2e%2e`) `..`; null bytes are rejected both literally and as a decoded `%00`; absolute-URL-looking input (a scheme, or `//host/...`) is rejected outright. `STATIC_CSP_HEADER_VALUE` is a SEPARATE, same-origin-permitting CSP (`script-src 'self'` etc., no `'unsafe-inline'`) from `security.js`'s `CSP_HEADER_VALUE` (`default-src 'none'`, unchanged, still used on every `/api/v1/*` response). |
29
+ | `http-server.js` (extended) | New static-asset branch, checked for every `GET` request whose path does NOT start with `/api/` — resolved BEFORE the session-token check (the ONE deliberate, disclosed exception to "every request requires a token": the token lives in a URL fragment the browser never sends, so the very first page-load request is structurally incapable of carrying it) but AFTER the Host-header check (T2 applies uniformly, auth or not). A rejected static path is a 404, never a 403 (never confirms a path's existence to a prober). Static responses carry `STATIC_CSP_HEADER_VALUE` + `Cache-Control: no-store` (unchanged) + the resolved Content-Type; `Access-Control-Allow-Origin` is still never set. The idle-auto-stop timer is deliberately NOT reset by an unauthenticated static-asset request — only genuine, authenticated `/api/v1/*` activity extends the session, unchanged from S1's own invariant. |
30
+ | `bin/agentic-security.js`'s `cmdExplore` (extended) | The printed URL changed from `http://127.0.0.1:<port>/api/v1/scan` to `http://127.0.0.1:<port>/#token=<64-hex-token>` — still the ONE place the token is ever displayed, now carried as a URL fragment instead of a separate "Token:" line, with updated wording ("open this URL in a browser" rather than "pass the token as a header manually"). |
31
+ | `frontend/src/lib/api-client.js` (new, in `frontend/`) | `extractTokenFromFragment()` (parses `location.hash` for `#token=<hex>`, tolerant of other hash params shell.js may also be writing) and `fetchGraph({token, baseUrl})` (fetches `/api/v1/graph` with the token header, unwraps the `.data` envelope field). `baseUrl` defaults to `''` (a same-origin relative fetch — the only thing the shipped page itself ever passes) and exists solely so a test can point this at a real running server without a headless browser. |
32
+ | `frontend/src/main.js` (new, in `frontend/`) | Replaces `index.html`'s old inline `<script type="module">` block (which would have needed `'unsafe-inline'` under `STATIC_CSP_HEADER_VALUE`) with an external module: extracts the token, calls `fetchGraph`, calls the EXISTING, UNCHANGED `bootstrap(root, graph)`, and shows a plain visible error in `#app-root` on fetch failure — never a silent blank page. |
33
+ | `test/server/static-assets.test.js` (new) | Pure-function tests — every real allowlisted path resolves with the right content type; several REAL on-disk-but-not-allowlisted files (`test/dom-shim.js`, `package.json`, `README.md`, `CLAUDE.md`, `.gitignore`, the generator script) are rejected; traversal (several encodings, including percent-encoded `..` and multi-segment escapes) and null-byte (literal and `%00`) rejection. |
34
+ | `test/server/http-server.test.js` (extended) | Real, live HTTP requests against a real running server for the new surface: `GET /` with NO token succeeds and serves the real `index.html`; `GET /api/v1/graph` with no token still 401 (the exemption is static-route-only); static responses carry the separate static CSP, never `default-src 'none'`; a forged `Host` header is still rejected on a static route; a live `../`-traversal attempt is rejected 404; the same on-disk-but-unlisted files are rejected 404 over real HTTP, never 403. |
35
+ | `test/server/cmd-explore.test.js` (updated) | Re-asserts the CLI's printed-URL regex against the new fragment-token form and that no separate "Token:" line is ever printed. |
36
+ | `frontend/test/api-client.test.js`, `frontend/test/live-fetch-parity.test.js` (new, in `frontend/`) | `extractTokenFromFragment`/`fetchGraph` unit tests (mocked fetch) plus the AC-16 proof: a REAL `explore` server started over the REAL flagship-graph fixture, fetched through the REAL `api-client.js` over a REAL loopback HTTP connection (no mock), asserted content-identical to the static-import baseline, then rendered through the SAME, UNCHANGED `bootstrap()` and asserted to produce a structurally IDENTICAL DOM tree to the existing static-import baseline's own render. |
37
+
38
+ ## What this does NOT do
39
+
40
+ `POST /api/v1/export` (the self-contained-export pipeline, §17.5 — Milestone 4 territory per the parent M3 scoping doc). Any Milestone 4/5 decision-intelligence endpoint (`stories/`, `scenarios/`, `snapshots/`, `diffs/`, `obligations/`, `runtime/`, `recipients/`, `impact/`, `remediation/`, `rescan`). Wiring `frontend/` to this server (Sub-project Wire's own job — this server is built and tested standalone, in complete isolation from `frontend/`). File/line evidence path resolution and its own T4 path-traversal confinement test (no such lookup exists in S1's five endpoints — the graph JSON is served verbatim, so T4 correctly has no surface yet, the same honest status the threat model doc itself records). Triggering a scan from `explore` itself. Any language beyond what the graph itself already covers (this server is language-agnostic — it serves whatever graph exists).
41
+
42
+ ## Conventions
43
+
44
+ - **No new npm dependency, ever, without re-opening this decision first.** `node:http`/`node:crypto`/`node:fs` only — matches this repo's own minimalism convention (`validate.js`'s own "no new npm dependency" precedent) and the MCP/LSP servers' own hand-rolled-protocol precedent. If a future increment genuinely needs a framework, that is a real, separately-justified decision, not a default.
45
+ - **CORS stays disabled by the ABSENCE of `Access-Control-Allow-Origin`.** Never add this header with a permissive value (`*`, or an echoed `Origin`) anywhere in this package without a real, disclosed threat-model reason — its absence is load-bearing security posture, not an oversight.
46
+ - **Every security-relevant string comparison goes through `constantTimeEqual`** (`security.js`), never `===`/`!==` on a token or secret.
47
+ - **A route handler's thrown error must never reach the client verbatim** — `http-server.js`'s own catch-and-genericize behavior around route dispatch is load-bearing; a future endpoint must not bypass it.
@@ -0,0 +1,141 @@
1
+ // graph-loader.js — Milestone 3, sub-project Server, increment 1.
2
+ //
3
+ // Reads and VERIFIES the `.agentic-security/lineage-graph.json` artifact
4
+ // before `explore` is allowed to serve a single byte of it. Reuses
5
+ // `posture/integrity.js`'s `verifyLastScan` DIRECTLY (per the plan and the
6
+ // root CLAUDE.md's own instruction) — this module does not implement any
7
+ // signature comparison of its own. `verifyLastScan` already uses
8
+ // `crypto.timingSafeEqual` internally.
9
+ //
10
+ // Loaded ONCE at server startup (see bin/agentic-security.js's cmdExplore)
11
+ // and held in memory for the life of the process — this is a read-only,
12
+ // single-scan-snapshot server; a change to the graph on disk mid-session is
13
+ // out of scope for this increment (threat-model doc's own "P0 is
14
+ // read-only" framing).
15
+
16
+ import * as fs from 'node:fs';
17
+ import { statePath } from '../posture/state-dir.js';
18
+ import { verifyLastScan } from '../posture/integrity.js';
19
+
20
+ /**
21
+ * @param {string} scanRoot
22
+ * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
23
+ *
24
+ * Four, and only four, distinct failure reasons — each with its own clear
25
+ * message so an operator knows exactly what to do next:
26
+ * - 'missing' — no lineage-graph.json at all. Run a scan with
27
+ * AGENTIC_SECURITY_LINEAGE_DEEP=1 first.
28
+ * - 'unsigned' — the graph exists but its .sig sibling does not
29
+ * (verifyLastScan returns null). Refuse to serve an
30
+ * unverifiable graph.
31
+ * - 'tampered' — the graph exists and has a .sig, but the signature does
32
+ * not match the body (verifyLastScan returns false). The
33
+ * file was modified after signing, or signed under a
34
+ * different install key.
35
+ * - 'malformed' — the body passed signature verification but is not
36
+ * valid JSON. Should not happen from a normal scan; the
37
+ * file may be corrupted on disk after signing.
38
+ */
39
+ export function loadSignedGraph(scanRoot) {
40
+ const graphPath = statePath(scanRoot, 'lineage-graph.json');
41
+ const sigPath = graphPath + '.sig';
42
+
43
+ if (!fs.existsSync(graphPath)) {
44
+ return {
45
+ ok: false,
46
+ reason: 'missing',
47
+ message: `No lineage graph found at ${graphPath}. Run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first (e.g. \`AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan\`), then re-run \`agentic-security explore\`.`,
48
+ };
49
+ }
50
+
51
+ let body;
52
+ try {
53
+ body = fs.readFileSync(graphPath, 'utf8');
54
+ } catch (e) {
55
+ return {
56
+ ok: false,
57
+ reason: 'missing',
58
+ message: `Lineage graph found at ${graphPath} but could not be read: ${e && e.message ? e.message : e}.`,
59
+ };
60
+ }
61
+
62
+ const verified = verifyLastScan(body, sigPath);
63
+ if (verified === null) {
64
+ return {
65
+ ok: false,
66
+ reason: 'unsigned',
67
+ message: `Lineage graph at ${graphPath} has no signature file (${sigPath} is missing). Refusing to serve an unverifiable graph. Re-run the scan (AGENTIC_SECURITY_LINEAGE_DEEP=1) to regenerate both files together.`,
68
+ };
69
+ }
70
+ if (verified === false) {
71
+ return {
72
+ ok: false,
73
+ reason: 'tampered',
74
+ message: `Lineage graph at ${graphPath} FAILED signature verification — its contents do not match ${sigPath}. The file may have been modified after the scan, or signed under a different install key. Refusing to serve a tampered graph. Re-run the scan to regenerate it.`,
75
+ };
76
+ }
77
+
78
+ let graph;
79
+ try {
80
+ graph = JSON.parse(body);
81
+ } catch (e) {
82
+ return {
83
+ ok: false,
84
+ reason: 'malformed',
85
+ message: `Lineage graph at ${graphPath} passed signature verification but is not valid JSON (${e && e.message ? e.message : e}). This should not happen from a normal scan — the file may be corrupted. Re-run the scan to regenerate it.`,
86
+ };
87
+ }
88
+
89
+ return { ok: true, graph };
90
+ }
91
+
92
+ /**
93
+ * Load .agentic-security/lineage-graph.json ONLY when it is genuinely
94
+ * fresh for THIS scan — never merely because a file happens to exist on
95
+ * disk. Shared by every caller that signs or narrates a graph:-derived
96
+ * compliance claim (M4 sub-project 6c's final whole-branch review found
97
+ * the identical staleness gap independently reachable from
98
+ * `attest --obligations` AND `compliance --walkthrough`, and required
99
+ * this predicate to live in exactly one place rather than being
100
+ * copy-pasted per caller — a safety check that drifts between two
101
+ * near-identical inline copies is worse than one shared bug).
102
+ *
103
+ * `.agentic-security/lineage-graph.json` is only rewritten when a scan
104
+ * actually finishes building a graph (`if (scan.lineageGraph)` in
105
+ * bin/agentic-security.js's persistence code) — an ordinary non-deep
106
+ * rescan, or a deep scan whose lineage build fails, leaves whatever file
107
+ * was there from an earlier successful deep scan untouched. Loading that
108
+ * stale graph and joining it to the CURRENT scan's other data would let a
109
+ * caller assert a graph-derived fact (e.g. "transit protected") about
110
+ * code that has since changed.
111
+ *
112
+ * `enabled: true` in `scan.scanHealth.lineageAnalysis` does NOT by itself
113
+ * mean the build succeeded — engine.js sets it the moment
114
+ * AGENTIC_SECURITY_LINEAGE_DEEP=1 is read, before the build even starts,
115
+ * and leaves it `true` even when the build later throws (only `failure`
116
+ * gets set in that case). `requested && enabled` alone therefore still
117
+ * accepts a stale graph after a failed rebuild — reproduced live via the
118
+ * scan's own already-shipped fault-injection fixture
119
+ * (test/lineage-fault-injection.test.js) before this `failure === null`
120
+ * check was added.
121
+ *
122
+ * @param {string} scanRoot
123
+ * @param {object} scan - the parsed last-scan.json for the CURRENT scan
124
+ * @returns {{graph:object|null, fresh:boolean, loaded:ReturnType<typeof loadSignedGraph>}}
125
+ * `fresh` is true only when a signed graph loaded successfully AND this
126
+ * scan's own scanHealth confirms lineage analysis was requested,
127
+ * enabled, and did not fail. `graph` is `loaded.graph` when fresh, else
128
+ * `null` — never the stale file, even when one exists on disk.
129
+ * `loaded` is the raw `loadSignedGraph` result, so a caller can still
130
+ * distinguish "no file at all" from "a file exists but isn't fresh" for
131
+ * its own disclosure message.
132
+ */
133
+ export function loadFreshLineageGraph(scanRoot, scan) {
134
+ const la = scan?.scanHealth?.lineageAnalysis;
135
+ const requested = la?.requested === true;
136
+ const enabled = la?.enabled === true;
137
+ const failure = la?.failure ?? null;
138
+ const loaded = loadSignedGraph(scanRoot);
139
+ const fresh = loaded.ok && requested && enabled && failure === null;
140
+ return { graph: fresh ? loaded.graph : null, fresh, loaded };
141
+ }