@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,497 @@
1
+ // observation-store.js — M5 deliverable #7 (Runtime-Corroborated Digital
2
+ // Twin, runtime-observed half only, "7b"; see runtime-observation.js's own
3
+ // header for why "7a", config-declared edges, is out of scope for this
4
+ // whole sub-project — scoping doc §4.0). FR-505, AC-29. The IMPURE layer:
5
+ // a directory of independently-readable, immutable whole files — one file
6
+ // per adapter IMPORT, never per observation.
7
+ //
8
+ // ── The storage ruling, and the three rejected candidates ───────────────
9
+ //
10
+ // Three existing state-storage shapes in this codebase were each considered
11
+ // and rejected, per the scoping doc's §4.4 (Correction 6):
12
+ //
13
+ // - `graph-snapshot.js`'s GraphSnapshot store is commit-keyed: one file
14
+ // per commit. That keying cannot express "many observation imports per
15
+ // graph entity" — an operator may import several adapter exports
16
+ // (different windows, different environments, a re-import after fixing
17
+ // a malformed source file) against the SAME commit, and commit-keying
18
+ // would collide them onto one file or silently overwrite.
19
+ // - `posture/provenance/lifecycle.js` rewrites its whole document on
20
+ // every update and is registered with a DELIBERATE no-`retentionClass`
21
+ // policy (permanent history, never auto-expired). An observation
22
+ // import is neither: it is not a document that grows by rewrite, and
23
+ // FR-505 requires it follow real retention/reset rules, not stay
24
+ // permanent forever.
25
+ // - `posture/remediation-ledger` (M5 deliverable #6)'s locked, hash-chained JSONL solves a
26
+ // read-fold-validate-write problem — many callers appending to ONE
27
+ // shared ledger, where the fold and the chain both depend on total
28
+ // order — that does not exist here: an import is a single, complete,
29
+ // independently-valid record with no fold step. Worse, its hash chain
30
+ // makes deletion structurally impossible (unlinking the middle of a
31
+ // chain breaks every entry after it), and FR-505 explicitly requires
32
+ // an observation store follow real retention/reset rules — an
33
+ // append-only hash chain and a retention-and-reset requirement are
34
+ // directly opposed.
35
+ //
36
+ // The choice, then: mirror `lineage-snapshots/`'s own directory-of-
37
+ // immutable-files shape, re-keyed from commit -> import. Each import is
38
+ // one whole, self-contained, independently-readable JSON file; nothing
39
+ // here ever rewrites a file in place, and nothing here ever folds two
40
+ // files into one logical state.
41
+ //
42
+ // ── No lock, and why (CORRECTED — final review B2) ─────────────────────
43
+ //
44
+ // Every import is an independent whole file. The only concurrency hazard a
45
+ // lock would address is two writers targeting the SAME file. This
46
+ // module's own header USED TO claim `observationImportId`'s `importedAt`
47
+ // discriminator alone made that impossible — that claim was FALSE:
48
+ // `importedAt` (`new Date().toISOString()`) is millisecond-resolution, so
49
+ // two concurrent `dataflow observations import --yes` invocations sharing
50
+ // adapter/source/environment/window and landing in the same millisecond
51
+ // minted the IDENTICAL import id, and the second write silently clobbered
52
+ // the first while BOTH processes reported success. Live-reproduced by the
53
+ // final review: 5 of 8 concurrent-round trials lost an entire import.
54
+ // Fixed two ways, belt and suspenders: (1) the CALLER
55
+ // (`cmdDataflowObservationsImport`, `bin/agentic-security.js`) now mints
56
+ // `observationImportId` with a fresh random discriminator part, so two
57
+ // invocations can never collide regardless of timing; (2) this module's
58
+ // own write is now ATOMIC (`_writeAtomicSync`, temp-file-then-rename)
59
+ // rather than a bare `writeFileSync`, so even a genuine same-name write
60
+ // race (a caller that skipped the id fix, or two callers racing on a
61
+ // hand-supplied id) can no longer produce a torn file — the worst case is
62
+ // now "one writer's complete content wins," never "a half-written file."
63
+ // There is still no read-fold-validate-write critical section anywhere in
64
+ // this module (contrast the remediation ledger, which locks for exactly
65
+ // that reason) — so there is still nothing for a LOCK specifically to
66
+ // protect; the fix is collision-proof ids plus an atomic write, not a lock.
67
+ //
68
+ // ── `statePath` is called with a STRING LITERAL at every site ──────────
69
+ //
70
+ // `test/artifact-registry-completeness.test.js`'s own `PATTERNS` regexes
71
+ // require a quoted literal as `statePath`'s second argument to detect a
72
+ // call site at all — a variable defeats the guard silently. This is not
73
+ // theoretical: `graph-snapshot.js:36` calls `statePath(scanRoot, HISTORY_DIR)`
74
+ // with a module constant, genuinely escapes the completeness guard today,
75
+ // and is registered in artifact-registry.js only because someone
76
+ // remembered by hand. `OBSERVATION_STORE_DIR` is exported below as a
77
+ // convenience for READERS (display strings, docs) but is deliberately
78
+ // never passed as `statePath`'s second argument anywhere in this file —
79
+ // `observationsDir` uses the literal `'runtime-observations'` directly, so
80
+ // the completeness guard has a real literal to see.
81
+ //
82
+ // ── Encryption is called explicitly, because the registry flag alone
83
+ // enforces nothing ─────────────────────────────────────────────────
84
+ //
85
+ // `confidential: true` on an artifact-registry.js entry is a DECLARATION,
86
+ // not an enforced control (see this sub-project's own scoping-doc
87
+ // Correction 1) — nothing reads that flag automatically at write time.
88
+ // The only two confidential artifacts in this tree before this module
89
+ // (`compliance-evidence.json`/`.md`) each call `maybeEncryptForWrite`/
90
+ // `maybeDecryptForRead` from their own writer (`posture/compliance-
91
+ // policy.js:497`/`:546`) — there is no ambient enforcement mechanism to
92
+ // inherit. `persistObservationImport`/`loadObservationImports`/
93
+ // `loadObservationImport` therefore call those two functions explicitly,
94
+ // exactly like that precedent, so the `confidential: true` this module's
95
+ // artifact-registry.js entry carries is backed by real behavior, not just
96
+ // a claim.
97
+ //
98
+ // ── Key-shape validation happens BEFORE any path.join ───────────────────
99
+ //
100
+ // `loadObservationImport(scanRoot, importId)` validates `importId`'s shape
101
+ // via `importFileName` before it ever builds a path — a disclosed,
102
+ // pre-existing gap this module deliberately does NOT inherit:
103
+ // `graph-snapshot.js`'s own `loadSnapshot(scanRoot, commitKey)` joins its
104
+ // caller-supplied key straight onto the history directory with no shape
105
+ // check at all (`graph-snapshot.js:150-152`), so a crafted `commitKey`
106
+ // could in principle read outside the state directory. Not exploited
107
+ // today (every real caller passes a git commit or a `--against` flag) and
108
+ // deliberately out of scope to fix there — this module's own header
109
+ // exists to record that this is a KNOWN, DISCLOSED asymmetry between the
110
+ // two sibling stores, not an oversight in either.
111
+
112
+ import * as fs from 'node:fs';
113
+ import * as path from 'node:path';
114
+ import * as crypto from 'node:crypto';
115
+ import { statePath, isSafeStateDir, stateWritesEnabled } from '../posture/state-dir.js';
116
+ import { maybeEncryptForWrite, maybeDecryptForRead } from '../posture/encryption-provider.js';
117
+ import { validateRuntimeObservation, RUNTIME_OBSERVATION_ADAPTERS } from './runtime-observation.js';
118
+ // I1 (final review): every other evidence-bearing artifact in this codebase
119
+ // (lineage-graph.json, last-scan.json, the remediation ledger's hash chain)
120
+ // carries tamper-evidence — the observation store did not, so a hand-planted
121
+ // forged import (a fabricated matchMethod/matchConfidence naming a real
122
+ // flow's ids) was indistinguishable from real evidence on read and could
123
+ // launder into the signed graph as genuine runtime corroboration. Reused
124
+ // UNCHANGED — the same generic, filename-agnostic HMAC primitive
125
+ // `lineage-graph.json` itself already uses (confirmed by direct read of
126
+ // this file: no filename baked in anywhere).
127
+ import { signLastScan, verifyLastScan } from '../posture/integrity.js';
128
+
129
+ /**
130
+ * The literal top-level directory name under `.agentic-security/`.
131
+ * Exported for readers (display strings, docs) — NEVER pass this as
132
+ * `statePath`'s second argument; see this file's own header for why.
133
+ */
134
+ export const OBSERVATION_STORE_DIR = 'runtime-observations';
135
+
136
+ export const OBSERVATION_IMPORT_VERSION = '1.0.0';
137
+
138
+ // The closed top-level key set of an ObservationImport record.
139
+ const IMPORT_FIELDS = Object.freeze([
140
+ 'id', 'version', 'adapter', 'source', 'environment', 'windowStart', 'windowEnd',
141
+ 'importedAt', 'retention', 'observations',
142
+ ]);
143
+
144
+ const _RETENTION_KEYS = ['expiresAt'];
145
+
146
+ function _isPlainObject(v) {
147
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
148
+ }
149
+
150
+ function _isNonEmptyString(v, maxLen) {
151
+ if (typeof v !== 'string' || v.length === 0) return false;
152
+ if (typeof maxLen === 'number' && v.length > maxLen) return false;
153
+ return true;
154
+ }
155
+
156
+ const _ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
157
+
158
+ function _isIsoDateTime(v) {
159
+ return typeof v === 'string' && _ISO_DATE_TIME_RE.test(v) && Number.isFinite(Date.parse(v));
160
+ }
161
+
162
+ /**
163
+ * The literal, verbatim `statePath` call this module's completeness-guard
164
+ * discipline depends on. Do not refactor this into a shared helper that
165
+ * takes the directory name as a parameter — `artifact-registry-
166
+ * completeness.test.js`'s `PATTERNS` needs a quoted literal at THIS call
167
+ * site to see it at all.
168
+ */
169
+ export function observationsDir(scanRoot) {
170
+ return statePath(scanRoot, 'runtime-observations');
171
+ }
172
+
173
+ // B2 (final review, Part 2): a faithful LOCAL PORT of the established
174
+ // temp-file-then-rename shape (`_writeConfigAtomic` in
175
+ // `bin/agentic-security.js`, `_writeAtomicAndSync` in
176
+ // `posture/fix-history.js`) — NOT an import, since both those helpers are
177
+ // module-private and this module's own `persistObservationImport` is
178
+ // synchronous (every real caller, including the CLI and this file's own
179
+ // test suite, calls it without `await`), so the async `fsp`-based originals
180
+ // cannot be reused directly. Mirrors the remediation-ledger module's own
181
+ // documented precedent (posture/remediation-ledger, M5 deliverable #6) for
182
+ // porting rather than importing an unexported helper. Temp file in the
183
+ // SAME directory (so the final `renameSync` is
184
+ // same-filesystem and therefore atomic), a random suffix (so two
185
+ // concurrent writers can never collide on the temp file itself even before
186
+ // B2 Part 1's id-collision fix), fsync before rename when available, and
187
+ // the temp file is unlinked on any failure so a crash never leaves a stray
188
+ // partial file behind. Closes the "torn file on a genuine write race" risk
189
+ // even after Part 1 makes true id collisions impossible — belt and
190
+ // suspenders — and fixes `loadObservationImports`'s own silent-swallow-of-
191
+ // a-torn-file gap as a side effect (a write can no longer be torn at all).
192
+ function _writeAtomicSync(fp, content) {
193
+ const dir = path.dirname(fp);
194
+ fs.mkdirSync(dir, { recursive: true });
195
+ const tmp = path.join(dir, `.${path.basename(fp)}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
196
+ try {
197
+ const fd = fs.openSync(tmp, 'w');
198
+ try {
199
+ fs.writeSync(fd, content);
200
+ fs.fsyncSync(fd);
201
+ } finally {
202
+ fs.closeSync(fd);
203
+ }
204
+ fs.renameSync(tmp, fp);
205
+ } catch (e) {
206
+ try { fs.unlinkSync(tmp); } catch { /* never existed, or already gone — fine either way */ }
207
+ throw e;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The store's own file-name key from an import id, or `null` when
213
+ * `importId` is not a well-formed `obsimport:`-prefixed id. Validates the
214
+ * key's SHAPE before any `path.join` — see this file's own header.
215
+ */
216
+ export function importFileName(importId) {
217
+ if (typeof importId !== 'string' || !/^obsimport:[0-9a-f]{12}$/.test(importId)) return null;
218
+ return `${importId.slice('obsimport:'.length)}.json`;
219
+ }
220
+
221
+ /**
222
+ * Closed-world structural validation of an ObservationImport record.
223
+ * Mirrors `runtime-observation.js#validateRuntimeObservation`'s own
224
+ * `{valid, errors}`/`[{path, message}]` shape and closed-world discipline
225
+ * one level up: every element of `observations[]` is routed through
226
+ * `validateRuntimeObservation` itself, with its errors re-pathed to
227
+ * `$.observations[i].<path>`. Never throws.
228
+ */
229
+ export function validateObservationImport(record) {
230
+ const errors = [];
231
+ const err = (p, message) => errors.push({ path: p, message });
232
+
233
+ if (!_isPlainObject(record)) {
234
+ err('$', 'ObservationImport record must be an object');
235
+ return { valid: false, errors };
236
+ }
237
+
238
+ // Closed-world sweep FIRST, exactly like validateRuntimeObservation's
239
+ // own discipline — an unrecognized top-level key is always an error.
240
+ for (const key of Object.keys(record)) {
241
+ if (!IMPORT_FIELDS.includes(key)) {
242
+ err(`$.${key}`, 'unknown field — ObservationImport records are closed-world: only approved fields are accepted, and an unrecognized field is rejected, never ignored');
243
+ }
244
+ }
245
+
246
+ for (const field of IMPORT_FIELDS) {
247
+ if (!(field in record)) err(`$.${field}`, `${field} is required`);
248
+ }
249
+
250
+ const idOk = 'id' in record && _isNonEmptyString(record.id) && record.id.startsWith('obsimport:');
251
+ if ('id' in record && !idOk) err('$.id', 'id is required and must start with "obsimport:"');
252
+
253
+ if ('version' in record && !_isNonEmptyString(record.version)) err('$.version', 'version is required');
254
+
255
+ if ('adapter' in record && !RUNTIME_OBSERVATION_ADAPTERS.includes(record.adapter)) {
256
+ err('$.adapter', `adapter must be one of ${RUNTIME_OBSERVATION_ADAPTERS.join('|')}`);
257
+ }
258
+
259
+ if ('source' in record && !_isNonEmptyString(record.source, 512)) {
260
+ err('$.source', 'source is required and must be at most 512 characters');
261
+ }
262
+
263
+ if ('environment' in record && !_isNonEmptyString(record.environment, 64)) {
264
+ err('$.environment', 'environment is required and must be at most 64 characters');
265
+ }
266
+
267
+ const windowStartOk = 'windowStart' in record && _isIsoDateTime(record.windowStart);
268
+ if ('windowStart' in record && !windowStartOk) err('$.windowStart', 'windowStart must be a parseable ISO-8601 date-time');
269
+
270
+ const windowEndOk = 'windowEnd' in record && _isIsoDateTime(record.windowEnd);
271
+ if ('windowEnd' in record && !windowEndOk) err('$.windowEnd', 'windowEnd must be a parseable ISO-8601 date-time');
272
+
273
+ if (windowStartOk && windowEndOk && Date.parse(record.windowStart) > Date.parse(record.windowEnd)) {
274
+ err('$.windowEnd', 'windowEnd must not be before windowStart');
275
+ }
276
+
277
+ if ('importedAt' in record && !_isIsoDateTime(record.importedAt)) {
278
+ err('$.importedAt', 'importedAt must be a parseable ISO-8601 date-time');
279
+ }
280
+
281
+ // retention: closed-key object with exactly `expiresAt`, mirroring
282
+ // runtime-observation.js's own field of the same name and shape.
283
+ if ('retention' in record) {
284
+ const retention = record.retention;
285
+ if (!_isPlainObject(retention)) {
286
+ err('$.retention', 'retention must be an object with exactly the key expiresAt');
287
+ } else {
288
+ for (const key of Object.keys(retention)) {
289
+ if (!_RETENTION_KEYS.includes(key)) {
290
+ err(`$.retention.${key}`, 'unknown field — retention is closed-world: only expiresAt is accepted');
291
+ }
292
+ }
293
+ const expiresAt = retention.expiresAt;
294
+ const expiresAtOk = expiresAt === null || _isIsoDateTime(expiresAt);
295
+ if (!expiresAtOk) {
296
+ err('$.retention.expiresAt', 'retention.expiresAt must be null or a parseable ISO-8601 date-time');
297
+ }
298
+ }
299
+ }
300
+
301
+ if ('observations' in record) {
302
+ if (!Array.isArray(record.observations)) {
303
+ err('$.observations', 'observations must be an array');
304
+ } else {
305
+ record.observations.forEach((obs, i) => {
306
+ const { errors: obsErrors } = validateRuntimeObservation(obs);
307
+ for (const e of obsErrors) {
308
+ errors.push({ path: `$.observations[${i}]${e.path.slice(1)}`, message: e.message });
309
+ }
310
+ });
311
+ }
312
+ }
313
+
314
+ return { valid: errors.length === 0, errors };
315
+ }
316
+
317
+ /**
318
+ * Persist `importRecord` as one immutable whole file, keyed by its own
319
+ * `id`, written ATOMICALLY (B2) and SIGNED (I1, a sibling `<file>.sig`
320
+ * carrying `signLastScan` of the exact bytes written). Refuses (never
321
+ * partially writes) when the record fails validation, when state writes
322
+ * are disabled, when the target directory is not a safe state directory,
323
+ * when the id cannot be turned into a file name, or when the
324
+ * confidentiality gate (`maybeEncryptForWrite`) itself refuses. The store
325
+ * is the last line of defense: no path exists by which an unvalidated,
326
+ * unsigned, or torn observation reaches disk.
327
+ *
328
+ * @returns {{ok:true, path:string} | {ok:false, reason:string}}
329
+ */
330
+ export function persistObservationImport(scanRoot, importRecord) {
331
+ const { valid, errors } = validateObservationImport(importRecord);
332
+ if (!valid) {
333
+ return { ok: false, reason: `invalid ObservationImport record: ${JSON.stringify(errors)}` };
334
+ }
335
+
336
+ if (!stateWritesEnabled()) {
337
+ return { ok: false, reason: 'state writes are disabled' };
338
+ }
339
+
340
+ const dir = observationsDir(scanRoot);
341
+ if (!isSafeStateDir(dir)) {
342
+ return { ok: false, reason: `refusing to write — ${dir} is not a safe state directory (no project marker found in its parent)` };
343
+ }
344
+
345
+ const fileName = importFileName(importRecord.id);
346
+ if (!fileName) {
347
+ return { ok: false, reason: `invalid import id "${importRecord.id}" — cannot derive a file name` };
348
+ }
349
+
350
+ const gated = maybeEncryptForWrite(scanRoot, 'runtime-observations', JSON.stringify(importRecord, null, 2));
351
+ if (!gated.ok) {
352
+ return gated;
353
+ }
354
+
355
+ const full = path.join(dir, fileName);
356
+ try {
357
+ fs.mkdirSync(dir, { recursive: true });
358
+ // B2 Part 2: atomic write (temp-file-then-rename), replacing the prior
359
+ // bare writeFileSync.
360
+ _writeAtomicSync(full, gated.content);
361
+ // I1: sign the EXACT bytes just written (post-encryption, if any) —
362
+ // the same content a reader will read back and verify against, mirroring
363
+ // `lineage-graph.json`'s own sign-what-you-wrote discipline.
364
+ _writeAtomicSync(`${full}.sig`, signLastScan(gated.content));
365
+ } catch (e) {
366
+ return { ok: false, reason: `write failed: ${e.message}` };
367
+ }
368
+
369
+ return { ok: true, path: full };
370
+ }
371
+
372
+ // I1: shared signature check for both readers below — a file nobody
373
+ // validated on write (planted by hand, or copied without its .sig sibling)
374
+ // must not become trusted by being on disk, at the SAME level of scrutiny
375
+ // a torn/malformed file already gets. `false` (tampered — the body doesn't
376
+ // match the .sig) and `null` (missing signature entirely — including every
377
+ // pre-existing unsigned import a hand-crafted forgery would produce) are
378
+ // both UNTRUSTED and treated identically: skip the record, never promote
379
+ // it to "valid" just because JSON.parse succeeded. Disclosed via a
380
+ // `console.error`, mirroring `recipient-registry.js#loadRecipientConfig`'s
381
+ // own established "tolerant degradation, never a silent drop with no
382
+ // trace" pattern — never an attribute key/value, only the file path and
383
+ // the verification outcome.
384
+ function _verifiedOrDisclose(full, raw) {
385
+ const verified = verifyLastScan(raw, `${full}.sig`);
386
+ if (verified === true) return true;
387
+ console.error(
388
+ verified === null
389
+ ? `agentic-security: runtime observation import ${full} has no .sig file — refusing to trust an unsigned import (a hand-planted forgery would look identical). Skipped.`
390
+ : `agentic-security: runtime observation import ${full} FAILED signature verification — its contents do not match ${full}.sig. Refusing to trust a tampered import. Skipped.`,
391
+ );
392
+ return false;
393
+ }
394
+
395
+ /**
396
+ * All persisted imports for scanRoot, newest first by mtime. Never
397
+ * throws — a missing/empty directory, a corrupt file, a non-`.json` file,
398
+ * an UNSIGNED or TAMPERED file (I1), or a file whose content fails
399
+ * `validateObservationImport` are all silently skipped, mirroring
400
+ * `graph-snapshot.js#loadSnapshots`'s own tolerance. A file nobody
401
+ * validated on write (planted by hand) must not become trusted by being on
402
+ * disk — hence the validate-on-read step, now preceded by the signature
403
+ * check.
404
+ */
405
+ export function loadObservationImports(scanRoot) {
406
+ const dir = observationsDir(scanRoot);
407
+ if (!fs.existsSync(dir)) return [];
408
+ let files;
409
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch { return []; }
410
+ const withMtime = files.map((f) => {
411
+ const full = path.join(dir, f);
412
+ let mtimeMs = 0;
413
+ try { mtimeMs = fs.statSync(full).mtimeMs; } catch { /* keep 0 */ }
414
+ return { full, mtimeMs };
415
+ });
416
+ withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
417
+ const out = [];
418
+ for (const { full } of withMtime) {
419
+ try {
420
+ const onDisk = fs.readFileSync(full, 'utf8');
421
+ if (!_verifiedOrDisclose(full, onDisk)) continue;
422
+ const raw = maybeDecryptForRead(onDisk);
423
+ const parsed = JSON.parse(raw);
424
+ const { valid } = validateObservationImport(parsed);
425
+ if (valid) out.push(parsed);
426
+ } catch { /* skip corrupt/unreadable file, never throw */ }
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * One import by its id, or `null`. Validates the key's shape before any
433
+ * `path.join` — see this file's own header. Never throws.
434
+ */
435
+ export function loadObservationImport(scanRoot, importId) {
436
+ const fileName = importFileName(importId);
437
+ if (!fileName) return null;
438
+ const full = path.join(observationsDir(scanRoot), fileName);
439
+ try {
440
+ const onDisk = fs.readFileSync(full, 'utf8');
441
+ if (!_verifiedOrDisclose(full, onDisk)) return null;
442
+ const raw = maybeDecryptForRead(onDisk);
443
+ const parsed = JSON.parse(raw);
444
+ const { valid } = validateObservationImport(parsed);
445
+ return valid ? parsed : null;
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Every observation across every persisted import, flattened and
453
+ * deduplicated by `id` (the newest import's copy wins, since
454
+ * `loadObservationImports` is newest-first), sorted by `id`. Never
455
+ * throws.
456
+ */
457
+ export function loadObservations(scanRoot) {
458
+ const imports = loadObservationImports(scanRoot);
459
+ const map = new Map();
460
+ for (const imp of imports) {
461
+ for (const obs of imp.observations ?? []) {
462
+ if (obs && typeof obs.id === 'string' && !map.has(obs.id)) {
463
+ map.set(obs.id, obs);
464
+ }
465
+ }
466
+ }
467
+ return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : 1));
468
+ }
469
+
470
+ /**
471
+ * Delete one import file by id. Validates the key's shape before any
472
+ * `path.join` — a malformed/traversal-shaped id refuses and unlinks
473
+ * nothing. Returns `true` on a real deletion, `false` otherwise
474
+ * (malformed id, file already gone, or an unlink error) — never throws.
475
+ *
476
+ * This is the property an append-only hash chain (the remediation ledger's
477
+ * own shape) could not provide: FR-505 requires an observation store
478
+ * follow real artifact encryption, RETENTION, RESET, access-control, and
479
+ * no-egress rules, and a hash chain makes deletion structurally
480
+ * impossible.
481
+ */
482
+ export function deleteObservationImport(scanRoot, importId) {
483
+ const fileName = importFileName(importId);
484
+ if (!fileName) return false;
485
+ const full = path.join(observationsDir(scanRoot), fileName);
486
+ try {
487
+ fs.unlinkSync(full);
488
+ // I1: best-effort cleanup of the sibling .sig — never load-bearing for
489
+ // this function's own true/false return (the main file's own unlink is
490
+ // what determines success/failure), just hygiene so a deleted import
491
+ // doesn't leave an orphaned signature file behind.
492
+ try { fs.unlinkSync(`${full}.sig`); } catch { /* absent or already gone — fine either way */ }
493
+ return true;
494
+ } catch {
495
+ return false;
496
+ }
497
+ }