@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,464 @@
1
+ // runtime-observation.js — the RuntimeObservation §10.10 extension contract
2
+ // (M5 deliverable #7, "Runtime-Corroborated Digital Twin" — this deliverable
3
+ // ships ONLY the runtime-observed half, "7b"; the config-declared half "7a"
4
+ // is out of scope for this whole sub-project — see the scoping doc's §4.0).
5
+ // FR-505, AC-29 ("Runtime observation remains metadata-only and
6
+ // non-exclusionary" — the Milestone 5 exit-gate clause this module is
7
+ // gated on). Pure by contract: zero imports, no fs, no I/O, never throws —
8
+ // mirrors `impact-assessment.js`'s/`flow-grade.js`'s own precedent exactly.
9
+ //
10
+ // ── Why this module is CLOSED-WORLD when every sibling §10.10 contract is
11
+ // open-world ──────────────────────────────────────────────────────────
12
+ //
13
+ // Every other extension-contract validator in this package
14
+ // (`impact-assessment.js`, `recipient-profile.js`, `scenario.js`,
15
+ // `obligation-mapping.js`, `graph-snapshot.js`) is open-world: it checks
16
+ // that the fields it cares about are well-formed and is silent about
17
+ // anything else on the record. That is the right default for a record
18
+ // built from the scanner's OWN already-vetted graph content.
19
+ //
20
+ // A RuntimeObservation is different in kind: it is built from an OPERATOR-
21
+ // SUPPLIED telemetry export (OpenTelemetry spans, access logs, a queue's
22
+ // own delivery metadata) that this codebase never generated and cannot
23
+ // vouch for. PRD line 983 states the requirement directly: "Runtime
24
+ // records use approved metadata schemas and **reject** fields capable of
25
+ // carrying payload values." AC-29 clause 5 restates it as the acceptance
26
+ // bar: "no captured payload, prompt, response, record, log message, or
27
+ // sensitive value exists in the observation artifact." Both are REJECT
28
+ // requirements, not "redact" or "best-effort" requirements — an open-world
29
+ // validator that merely checks the fields it recognizes and ignores the
30
+ // rest would satisfy neither: any attribute name nobody thought to name in
31
+ // advance sails through unexamined, and a telemetry payload field is
32
+ // exactly the kind of key nobody names in advance (a caller controls the
33
+ // export, not this codebase). A scrub-known-bad DENYLIST has the identical
34
+ // failure mode one level down — it fails open on every attribute name
35
+ // nobody thought of. So this module allowlists both the top-level record
36
+ // shape (`RUNTIME_OBSERVATION_FIELDS`) and the attribute-key vocabulary
37
+ // (`RUNTIME_ATTRIBUTE_KEYS`), and rejects anything outside either. A future
38
+ // "simplification" that widens either sweep from "reject unknown" to
39
+ // "ignore unknown" is a silent AC-29 falsification, not a cleanup — it
40
+ // would let an unreviewed payload-shaped field or attribute key pass
41
+ // straight through this artifact.
42
+ //
43
+ // ── Reports, never mutates ─────────────────────────────────────────────
44
+ //
45
+ // `validateRuntimeObservation`/`validateObservationAttributes` never
46
+ // mutate, delete, or scrub their input — they return `{valid, errors}` and
47
+ // leave the record untouched (RO/4h). A scrubbing validator would let a
48
+ // caller persist the SCRUBBED REMAINDER as though it had been clean all
49
+ // along, silently laundering a rejected record into an accepted one one
50
+ // call site downstream. Reject-and-report keeps that decision visible to
51
+ // the caller, matching PRD line 983's own "reject" wording rather than an
52
+ // implied "redact".
53
+ //
54
+ // ── eventCountBand is a band, not a count ──────────────────────────────
55
+ //
56
+ // PRD line 971 specifies a "count/frequency band," not a raw number — an
57
+ // exact event count is itself a weak information channel (it can leak
58
+ // approximate traffic volume/business metrics through an artifact whose
59
+ // only job is "this destination was or wasn't observed"). `EVENT_COUNT_BANDS`
60
+ // is therefore a closed set of coarse buckets; `validateRuntimeObservation`
61
+ // rejects any numeric or free-form value in that field, banded or not.
62
+ //
63
+ // ── 'ambiguous' is enforced, not just documented ───────────────────────
64
+ //
65
+ // FR-505 requires that an ambiguous observation (more than one candidate
66
+ // node matched) remain a CANDIDATE and never silently merge distinct data
67
+ // elements. This module enforces that as a real cross-field rule (RO/6f):
68
+ // `matchedNodeIds.length > 1` REQUIRES `matchConfidence === 'ambiguous'` —
69
+ // a caller cannot report two candidate nodes at `'high'`/`'medium'`/`'low'`
70
+ // confidence, which would read as a confident but silently-arbitrary pick
71
+ // between them.
72
+ //
73
+ // ── OBSERVATION_LAYERS is three-valued on purpose ──────────────────────
74
+ //
75
+ // PRD line 2098 forbids treating "not observed" as "not occurring" — an
76
+ // unobserved flow may simply be outside the telemetry window, not absent
77
+ // from the system. `OBSERVATION_LAYERS` therefore keeps
78
+ // `not_observed_in_window` (evaluated, genuinely absent from the evidence)
79
+ // and `not_evaluated` (no attempt was made at all) as two GENUINELY
80
+ // DIFFERENT answers — the same distinction `flow.policyVerdict`'s own
81
+ // `not_evaluated` value already draws for a missing `privacy-policy.json`
82
+ // (Milestone 2, Sub-project G). Collapsing the two into one "no runtime
83
+ // evidence" bucket would silently misrepresent an unevaluated flow as one
84
+ // the operator actually checked and found nothing for.
85
+ //
86
+ // ── Scope: 7b only ──────────────────────────────────────────────────────
87
+ //
88
+ // This deliverable ships the RUNTIME-OBSERVED half of the Digital Twin
89
+ // only. The CONFIG-DECLARED half (7a — inferring an edge from IaC/config
90
+ // rather than from executed code) is Milestone 2 Sub-project F2/F3's own
91
+ // job (scoping doc §4.0) and is explicitly out of scope here — nothing in
92
+ // this module mints a non-code-derived edge or otherwise touches
93
+ // `edge.provenance`.
94
+ //
95
+ // ── Disclosed, accepted residual: short-word array smuggling ──────────
96
+ //
97
+ // Even with schema.attributeNames' own stricter per-element grammar
98
+ // (letters/digits/underscore only, no ':'/'.'/'-', 8 elements max), an
99
+ // adversarial or misconfigured exporter can still supply up to 8 short,
100
+ // individually-plausible-looking single words as separate array elements
101
+ // (e.g. a handful of English words split one-per-element). This is an
102
+ // INHERENT limitation of any bounded, per-element, character-class-only
103
+ // grammar with no semantic/natural-language-detection layer — the same
104
+ // class of residual as a base64-encoded secret hidden inside an
105
+ // otherwise-valid single value (also disclosed, also not attempted here).
106
+ // A real fix needs prose/sentence detection across the whole array, which
107
+ // is expensive and fuzzy by nature — explicitly out of scope for this
108
+ // deliverable. What this round's fix DOES close completely: every
109
+ // key:value-shaped smuggling case (a colon-structured secret like
110
+ // "password:hunter2"), which is what turned the original live repro into
111
+ // an unambiguous, directly-readable credential/PII leak rather than a mere
112
+ // word list. Operators integrating an automated telemetry exporter into an
113
+ // unattended `dataflow observations import` pipeline should still audit
114
+ // what that exporter actually sends.
115
+ //
116
+ // ── Data/artifact layer only ────────────────────────────────────────────
117
+ //
118
+ // AC-29's clauses are satisfied here at the data/artifact layer alone — no
119
+ // UI displays a RuntimeObservation record yet (scoping doc §4.7). A later
120
+ // UI increment inherits five properties from this contract it must not
121
+ // break: (1) closed-world field/attribute allowlisting, (2) report-not-
122
+ // mutate validation, (3) banded (never exact) event counts, (4) enforced
123
+ // (never advisory) ambiguity handling, (5) the three-valued observation
124
+ // layer. Breaking any of those at the UI layer would reopen exactly the
125
+ // gap this module exists to close.
126
+
127
+ export const RUNTIME_OBSERVATION_VERSION = '1.0.0';
128
+
129
+ // The 18 allowed top-level keys of a RuntimeObservation record — the
130
+ // closed-world sweep in `validateRuntimeObservation` rejects anything
131
+ // outside this set and requires everything inside it.
132
+ export const RUNTIME_OBSERVATION_FIELDS = Object.freeze([
133
+ 'id', 'version', 'adapter', 'source', 'environment', 'windowStart', 'windowEnd',
134
+ 'matchedNodeIds', 'matchedEdgeIds', 'matchedFlowIds', 'attributes', 'eventCountBand',
135
+ 'firstObservedAt', 'lastObservedAt', 'matchMethod', 'matchConfidence', 'retention', 'importedAt',
136
+ ]);
137
+
138
+ // Only one adapter is actually implemented today — an unimplemented
139
+ // adapter name must be rejected, not accepted on faith that it will
140
+ // someday exist (RO/3b).
141
+ export const RUNTIME_OBSERVATION_ADAPTERS = Object.freeze(['native-jsonl']);
142
+
143
+ // FR-505's own four named metadata families: service/workload identity,
144
+ // endpoint/destination identity, protocol/TLS metadata, and schema/
145
+ // attribute NAMES already approved for telemetry (never attribute VALUES,
146
+ // which is exactly the payload channel AC-29 forbids).
147
+ export const RUNTIME_ATTRIBUTE_KEYS = Object.freeze([
148
+ // service/workload identity
149
+ 'service.name', 'service.namespace', 'service.version', 'service.instance.id',
150
+ 'workload.name', 'workload.kind',
151
+ // endpoint or destination identity — 'destination.path' REMOVED (final
152
+ // review B1): it had zero consumers anywhere in this codebase and is a
153
+ // pure payload-smuggling channel (a URL path/query string can carry
154
+ // arbitrary key=value content by construction) — the module's own
155
+ // stated design ("REJECT, not redact") is best served by not accepting
156
+ // a field with no analytic value at all, rather than trying to sanitize
157
+ // one.
158
+ 'destination.host', 'destination.port', 'destination.scheme', 'destination.service',
159
+ // protocol/TLS metadata
160
+ 'network.protocol', 'network.transport', 'tls.version', 'tls.cipher', 'tls.verified',
161
+ // schema/attribute NAMES already approved for telemetry
162
+ 'schema.name', 'schema.attributeNames',
163
+ ]);
164
+
165
+ // The one attribute key whose value is an array rather than a scalar —
166
+ // itself must be a member of RUNTIME_ATTRIBUTE_KEYS.
167
+ export const RUNTIME_ARRAY_ATTRIBUTE_KEYS = Object.freeze(['schema.attributeNames']);
168
+
169
+ // A value-axis identifier grammar (final review B1): every approved
170
+ // attribute key holds an identifier-shaped token (a hostname, a port, a
171
+ // protocol/cipher/TLS-version string, a service/workload name, a table or
172
+ // attribute name) — none has a legitimate reason to contain whitespace, a
173
+ // quote, '=', '?', '#', '<', '>', ';', or free-form prose. This is what
174
+ // makes AC-29 clause 5 genuinely true rather than merely closed on the key
175
+ // axis: a value that doesn't look like an identifier is REJECTED outright
176
+ // (PRD line 983's own "reject", never "redact"), not truncated or
177
+ // sanitized. Tightened from the prior 256-char cap (which alone let an
178
+ // 18.5KB multi-entry payload through via schema.attributeNames' 64-entry
179
+ // array) to 128 — still generous for any real hostname/service/table name,
180
+ // and small enough that it is no longer a meaningful free-text channel.
181
+ export const RUNTIME_ATTRIBUTE_MAX_VALUE_LENGTH = 128; // was 256 — see comment above
182
+ export const RUNTIME_ATTRIBUTE_MAX_ARRAY_LENGTH = 8; // was 64 — see final review round 2 (I-NEW1)
183
+ export const RUNTIME_ATTRIBUTE_MAX_KEYS = 32;
184
+
185
+ const _IDENTIFIER_VALUE_RE = /^[A-Za-z0-9._:-]{1,128}$/;
186
+
187
+ function _isIdentifierValue(v) {
188
+ return typeof v === 'string' && v.length <= RUNTIME_ATTRIBUTE_MAX_VALUE_LENGTH && _IDENTIFIER_VALUE_RE.test(v);
189
+ }
190
+
191
+ // schema.attributeNames-specific grammar (final review round 2, I-NEW1): a
192
+ // real attribute/field NAME is always a single identifier token — letters,
193
+ // digits, underscore only, starting with a letter. It never legitimately
194
+ // contains ':', '.', or '-' — those are precisely what let an individual
195
+ // array element become a `key:value` pair (the round-1 re-review's own
196
+ // live repro: "password:hunter2", "ssn:123-45-6789",
197
+ // "pan:4111111111111111", each individually "identifier-shaped" under the
198
+ // GENERAL _isIdentifierValue grammar, since that grammar allows ':' for
199
+ // OTHER keys that genuinely need it — destination.host doesn't need a
200
+ // colon, but this field never needs one at all). Forbidding ':'/'.'/'-'
201
+ // here closes every key:value-shaped smuggling case completely, while
202
+ // still accepting every real attribute name shape (user_id, email,
203
+ // created_at, SSN, PAN — all pass; none of the disclosed-in-repro secrets
204
+ // do).
205
+ const _ATTRIBUTE_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
206
+
207
+ function _isAttributeName(v) {
208
+ return typeof v === 'string' && _ATTRIBUTE_NAME_RE.test(v);
209
+ }
210
+
211
+ // A count/frequency BAND (PRD line 971) — never a raw number. An exact
212
+ // event count is itself a weak information channel.
213
+ export const EVENT_COUNT_BANDS = Object.freeze(['1', '2-10', '11-100', '101-1k', '1k+']);
214
+
215
+ export const RUNTIME_MATCH_METHODS = Object.freeze(['destination_literal', 'store_table', 'queue_topic', 'unmatched']);
216
+ export const RUNTIME_MATCH_CONFIDENCE = Object.freeze(['high', 'medium', 'low', 'ambiguous']);
217
+
218
+ // Three-valued deliberately — see the header comment above for why
219
+ // `not_observed_in_window` and `not_evaluated` must never collapse into
220
+ // one "no evidence" bucket (PRD line 2098).
221
+ export const OBSERVATION_LAYERS = Object.freeze(['runtime_observed', 'not_observed_in_window', 'not_evaluated']);
222
+
223
+ const _RETENTION_KEYS = ['expiresAt'];
224
+
225
+ function _isPlainObject(v) {
226
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
227
+ }
228
+
229
+ function _isNonEmptyString(v, maxLen) {
230
+ if (typeof v !== 'string' || v.length === 0) return false;
231
+ if (typeof maxLen === 'number' && v.length > maxLen) return false;
232
+ return true;
233
+ }
234
+
235
+ const _ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
236
+
237
+ function _isIsoDateTime(v) {
238
+ return typeof v === 'string' && _ISO_DATE_TIME_RE.test(v) && Number.isFinite(Date.parse(v));
239
+ }
240
+
241
+ function _isPrefixedIdArray(v, prefix) {
242
+ return Array.isArray(v) && v.every((x) => typeof x === 'string' && x.startsWith(prefix));
243
+ }
244
+
245
+ /**
246
+ * Closed-world validation of a RuntimeObservation record's `attributes`
247
+ * map (AC-29 clause 5's own core enforcement point). Never throws.
248
+ */
249
+ export function validateObservationAttributes(attributes) {
250
+ const errors = [];
251
+ const err = (p, message) => errors.push({ path: p, message });
252
+
253
+ if (!_isPlainObject(attributes)) {
254
+ err('$.attributes', 'attributes must be a plain object');
255
+ return { valid: false, errors };
256
+ }
257
+
258
+ const keys = Object.keys(attributes);
259
+ if (keys.length > RUNTIME_ATTRIBUTE_MAX_KEYS) {
260
+ err('$.attributes', `attributes may carry at most ${RUNTIME_ATTRIBUTE_MAX_KEYS} keys, got ${keys.length}`);
261
+ }
262
+
263
+ for (const [key, value] of Object.entries(attributes)) {
264
+ if (!RUNTIME_ATTRIBUTE_KEYS.includes(key)) {
265
+ err(`$.attributes["${key}"]`, 'unapproved attribute key — RuntimeObservation attributes are closed-world (FR-505): only the approved metadata allowlist is accepted, and an unrecognized key is rejected, never ignored');
266
+ continue; // the value of an unapproved key is not further inspected
267
+ }
268
+ if (RUNTIME_ARRAY_ATTRIBUTE_KEYS.includes(key)) {
269
+ if (!Array.isArray(value) || value.length > RUNTIME_ATTRIBUTE_MAX_ARRAY_LENGTH
270
+ || !value.every((x) => _isAttributeName(x))) {
271
+ err(`$.attributes["${key}"]`, `must be an array of at most ${RUNTIME_ATTRIBUTE_MAX_ARRAY_LENGTH} attribute-name-shaped strings (letters, digits, underscore only, starting with a letter, 64 characters max) — an element containing ':', '.', '-', whitespace, or other punctuation is how a payload arrives disguised as an attribute name`);
272
+ }
273
+ continue;
274
+ }
275
+ const t = typeof value;
276
+ if (t === 'string') {
277
+ if (!_isIdentifierValue(value)) {
278
+ err(`$.attributes["${key}"]`, `string value must look like an identifier (letters, digits, '.', '_', ':', '-', ${RUNTIME_ATTRIBUTE_MAX_VALUE_LENGTH} characters max) — a value containing whitespace, quotes, or punctuation is how a payload arrives disguised as metadata`);
279
+ }
280
+ } else if (t === 'number') {
281
+ if (!Number.isFinite(value)) {
282
+ err(`$.attributes["${key}"]`, 'numeric attribute values must be finite');
283
+ }
284
+ } else if (t === 'boolean') {
285
+ // fine
286
+ } else {
287
+ err(`$.attributes["${key}"]`, `attribute value must be a string, number, or boolean scalar, got ${value === null ? 'null' : t}`);
288
+ }
289
+ }
290
+
291
+ return { valid: errors.length === 0, errors };
292
+ }
293
+
294
+ /**
295
+ * Closed-world structural validation of a RuntimeObservation record
296
+ * (M5 deliverable #7b, FR-505 §10.10, AC-29 clause 5). Never throws.
297
+ * Reports, never mutates (RO/4h).
298
+ */
299
+ export function validateRuntimeObservation(record) {
300
+ const errors = [];
301
+ const err = (p, message) => errors.push({ path: p, message });
302
+
303
+ if (!_isPlainObject(record)) {
304
+ err('$', 'RuntimeObservation record must be an object');
305
+ return { valid: false, errors };
306
+ }
307
+
308
+ // Closed-world sweep FIRST, before anything else — an unrecognized
309
+ // top-level key is always an error, regardless of whatever else is
310
+ // wrong or right about the record.
311
+ for (const key of Object.keys(record)) {
312
+ if (!RUNTIME_OBSERVATION_FIELDS.includes(key)) {
313
+ err(`$.${key}`, 'unknown field — RuntimeObservation records are closed-world (PRD line 983): only approved metadata fields are accepted, and an unrecognized field is rejected, never ignored');
314
+ }
315
+ }
316
+
317
+ // Missing-required sweep — closed-world cuts both ways.
318
+ for (const field of RUNTIME_OBSERVATION_FIELDS) {
319
+ if (!(field in record)) {
320
+ err(`$.${field}`, `${field} is required`);
321
+ }
322
+ }
323
+
324
+ // Per-field type/enum checks — each guarded so a malformed field
325
+ // doesn't get double-reported by a cross-field rule below.
326
+ const idOk = _isNonEmptyString(record.id) && record.id.startsWith('observation:');
327
+ if ('id' in record && !idOk) err('$.id', 'id is required and must start with "observation:"');
328
+
329
+ if ('version' in record && !_isNonEmptyString(record.version)) err('$.version', 'version is required');
330
+
331
+ if ('adapter' in record && !RUNTIME_OBSERVATION_ADAPTERS.includes(record.adapter)) {
332
+ err('$.adapter', `adapter must be one of ${RUNTIME_OBSERVATION_ADAPTERS.join('|')}`);
333
+ }
334
+
335
+ if ('source' in record && !_isNonEmptyString(record.source, 512)) {
336
+ err('$.source', 'source is required and must be at most 512 characters');
337
+ }
338
+
339
+ if ('environment' in record && !_isNonEmptyString(record.environment, 64)) {
340
+ err('$.environment', 'environment is required and must be at most 64 characters');
341
+ }
342
+
343
+ const windowStartOk = 'windowStart' in record && _isIsoDateTime(record.windowStart);
344
+ if ('windowStart' in record && !windowStartOk) err('$.windowStart', 'windowStart must be a parseable ISO-8601 date-time');
345
+
346
+ const windowEndOk = 'windowEnd' in record && _isIsoDateTime(record.windowEnd);
347
+ if ('windowEnd' in record && !windowEndOk) err('$.windowEnd', 'windowEnd must be a parseable ISO-8601 date-time');
348
+
349
+ const matchedNodeIdsOk = 'matchedNodeIds' in record && _isPrefixedIdArray(record.matchedNodeIds, 'node:');
350
+ if ('matchedNodeIds' in record && !matchedNodeIdsOk) {
351
+ err('$.matchedNodeIds', 'matchedNodeIds must be an array of node:-prefixed ids');
352
+ }
353
+
354
+ const matchedEdgeIdsOk = 'matchedEdgeIds' in record && _isPrefixedIdArray(record.matchedEdgeIds, 'edge:');
355
+ if ('matchedEdgeIds' in record && !matchedEdgeIdsOk) {
356
+ err('$.matchedEdgeIds', 'matchedEdgeIds must be an array of edge:-prefixed ids');
357
+ }
358
+
359
+ const matchedFlowIdsOk = 'matchedFlowIds' in record && _isPrefixedIdArray(record.matchedFlowIds, 'flow:');
360
+ if ('matchedFlowIds' in record && !matchedFlowIdsOk) {
361
+ err('$.matchedFlowIds', 'matchedFlowIds must be an array of flow:-prefixed ids');
362
+ }
363
+
364
+ if ('attributes' in record) {
365
+ const attrResult = validateObservationAttributes(record.attributes);
366
+ errors.push(...attrResult.errors);
367
+ }
368
+
369
+ const eventCountBandOk = 'eventCountBand' in record && EVENT_COUNT_BANDS.includes(record.eventCountBand);
370
+ if ('eventCountBand' in record && !eventCountBandOk) {
371
+ err('$.eventCountBand', `eventCountBand must be a band (${EVENT_COUNT_BANDS.join('|')}) — an exact count is itself an information channel`);
372
+ }
373
+
374
+ const firstObservedAtOk = 'firstObservedAt' in record && _isIsoDateTime(record.firstObservedAt);
375
+ if ('firstObservedAt' in record && !firstObservedAtOk) err('$.firstObservedAt', 'firstObservedAt must be a parseable ISO-8601 date-time');
376
+
377
+ const lastObservedAtOk = 'lastObservedAt' in record && _isIsoDateTime(record.lastObservedAt);
378
+ if ('lastObservedAt' in record && !lastObservedAtOk) err('$.lastObservedAt', 'lastObservedAt must be a parseable ISO-8601 date-time');
379
+
380
+ const matchMethodOk = 'matchMethod' in record && RUNTIME_MATCH_METHODS.includes(record.matchMethod);
381
+ if ('matchMethod' in record && !matchMethodOk) {
382
+ err('$.matchMethod', `matchMethod must be one of ${RUNTIME_MATCH_METHODS.join('|')}`);
383
+ }
384
+
385
+ const matchConfidenceOk = 'matchConfidence' in record && RUNTIME_MATCH_CONFIDENCE.includes(record.matchConfidence);
386
+ if ('matchConfidence' in record && !matchConfidenceOk) {
387
+ err('$.matchConfidence', `matchConfidence must be one of ${RUNTIME_MATCH_CONFIDENCE.join('|')}`);
388
+ }
389
+
390
+ // retention: closed-key object with exactly `expiresAt`, which is
391
+ // either null or a parseable ISO-8601 date-time.
392
+ if ('retention' in record) {
393
+ const retention = record.retention;
394
+ if (!_isPlainObject(retention)) {
395
+ err('$.retention', 'retention must be an object with exactly the key expiresAt');
396
+ } else {
397
+ for (const key of Object.keys(retention)) {
398
+ if (!_RETENTION_KEYS.includes(key)) {
399
+ err(`$.retention.${key}`, 'unknown field — retention is closed-world: only expiresAt is accepted');
400
+ }
401
+ }
402
+ const expiresAt = retention.expiresAt;
403
+ const expiresAtOk = expiresAt === null || _isIsoDateTime(expiresAt);
404
+ if (!expiresAtOk) {
405
+ err('$.retention.expiresAt', 'retention.expiresAt must be null or a parseable ISO-8601 date-time');
406
+ }
407
+ }
408
+ }
409
+
410
+ if ('importedAt' in record && !_isIsoDateTime(record.importedAt)) {
411
+ err('$.importedAt', 'importedAt must be a parseable ISO-8601 date-time');
412
+ }
413
+
414
+ // ── Cross-field rules — each guarded so it only fires when both
415
+ // operands are already well-formed (never cascade a window-ordering
416
+ // error off a malformed date). ──────────────────────────────────
417
+
418
+ if (windowStartOk && windowEndOk) {
419
+ if (Date.parse(record.windowStart) > Date.parse(record.windowEnd)) {
420
+ err('$.windowEnd', 'windowEnd must not be before windowStart');
421
+ }
422
+ }
423
+
424
+ if (firstObservedAtOk && lastObservedAtOk) {
425
+ if (Date.parse(record.firstObservedAt) > Date.parse(record.lastObservedAt)) {
426
+ err('$.lastObservedAt', 'lastObservedAt must not be before firstObservedAt');
427
+ }
428
+ }
429
+
430
+ if (firstObservedAtOk && windowStartOk) {
431
+ if (Date.parse(record.firstObservedAt) < Date.parse(record.windowStart)) {
432
+ err('$.firstObservedAt', 'firstObservedAt must fall on or after windowStart');
433
+ }
434
+ }
435
+
436
+ if (lastObservedAtOk && windowEndOk) {
437
+ if (Date.parse(record.lastObservedAt) > Date.parse(record.windowEnd)) {
438
+ err('$.lastObservedAt', 'lastObservedAt must fall on or before windowEnd');
439
+ }
440
+ }
441
+
442
+ if (matchMethodOk && matchedNodeIdsOk && matchedEdgeIdsOk && matchedFlowIdsOk) {
443
+ const allEmpty = record.matchedNodeIds.length === 0 && record.matchedEdgeIds.length === 0
444
+ && record.matchedFlowIds.length === 0;
445
+ if (record.matchMethod === 'unmatched') {
446
+ if (!allEmpty) {
447
+ err('$.matchMethod', 'matchMethod "unmatched" requires every matched*Ids array to be empty — a matched id contradicts an "unmatched" record');
448
+ }
449
+ } else if (record.matchedNodeIds.length === 0) {
450
+ err('$.matchedNodeIds', 'a non-"unmatched" matchMethod requires at least one matched node id');
451
+ }
452
+ }
453
+
454
+ // Deliberately scoped to matchedNodeIds only — edges and flows derive
455
+ // from a matched node, so several of them is normal and not itself an
456
+ // ambiguity; a rule over the union of all three arrays would fire on
457
+ // every ordinary match (a node with 2 edges and 3 flows, say).
458
+ if (matchedNodeIdsOk && matchConfidenceOk && record.matchedNodeIds.length > 1
459
+ && record.matchConfidence !== 'ambiguous') {
460
+ err('$.matchConfidence', 'more than one matched node requires matchConfidence "ambiguous" — an ambiguous observation must remain a candidate and never silently merge data elements (FR-505)');
461
+ }
462
+
463
+ return { valid: errors.length === 0, errors };
464
+ }
@@ -0,0 +1,84 @@
1
+ // scenario-diff.js — M5 deliverable #3a (FR-502): a dedicated
2
+ // comparison for a Scenario's own simulated graph delta. Deliberately
3
+ // NOT graph-diff.js's computeGraphDiff — see this sub-project's own
4
+ // scoping doc for the full reasoning: computeGraphDiff's flow-
5
+ // reidentification pairing and causeClassification vocabulary
6
+ // (`'application_change'`, `'possible_coverage_regression'`,
7
+ // `'reidentified'`) are shaped for a REAL rescan across two commits,
8
+ // where the cause of a change is genuinely ambiguous. A Scenario's own
9
+ // delta has no such ambiguity — every difference between the base graph
10
+ // and a scenario clone IS the declared hypothetical operation that
11
+ // produced it — so this module reports only "what differs", with no
12
+ // cause classification and no reidentification.
13
+
14
+ export const WATCHED_SCENARIO_FIELDS = Object.freeze({
15
+ // 'destination' + 'storeDetail' are exhaustive by construction, not by
16
+ // convention: change_storage_fact/change_governance_fact write into an
17
+ // already-watched CONTAINER object, so any field they set is caught
18
+ // regardless of key; replace_recipient_fact is the one operation that
19
+ // writes directly onto the node's own top level, and scenario-engine.js
20
+ // restricts it, at the applier level, to exactly node.destination (any
21
+ // other `field` value is skipped, never applied) — so this list never
22
+ // needs to name a field replace_recipient_fact could reach beyond
23
+ // 'destination'. See scenario-engine.js's _applyReplaceRecipientFact.
24
+ node: Object.freeze(['destination', 'storeDetail']),
25
+ edge: Object.freeze(['protection.transit', 'protection.atRest', 'protection.handling']),
26
+ flow: Object.freeze(['policyVerdict', 'protectionSummary', 'governanceRefs']),
27
+ });
28
+
29
+ function _get(obj, dottedPath) {
30
+ return dottedPath.split('.').reduce((v, k) => (v == null ? v : v[k]), obj);
31
+ }
32
+
33
+ function _deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
34
+
35
+ function _byId(arr) { return new Map((arr ?? []).map((x) => [x.id, x])); }
36
+
37
+ function _diffKind(beforeArr, afterArr, kind) {
38
+ const beforeMap = _byId(beforeArr);
39
+ const afterMap = _byId(afterArr);
40
+ const changed = [];
41
+ const removed = [];
42
+ for (const [id, beforeEntity] of beforeMap) {
43
+ const afterEntity = afterMap.get(id);
44
+ if (!afterEntity) { removed.push(id); continue; }
45
+ const changedFields = [];
46
+ for (const field of WATCHED_SCENARIO_FIELDS[kind]) {
47
+ const before = _get(beforeEntity, field);
48
+ const after = _get(afterEntity, field);
49
+ if (!_deepEqual(before, after)) changedFields.push({ field, before, after });
50
+ }
51
+ if (changedFields.length) changed.push({ id, kind, changedFields });
52
+ }
53
+ return { changed, removed };
54
+ }
55
+
56
+ // AC-26 ("What-if changes cannot masquerade as implementation"): a
57
+ // simulated edge must be LABELED `HYPOTHETICAL`, not merely carry an
58
+ // `evidenceGrade: 'assumed'` value a reader has to notice buried inside
59
+ // a before/after diff. Mirrors AC-29's own precedent
60
+ // (`observation-correlation.js`'s literal `'runtime_observed'` layer
61
+ // value) — a named acceptance criterion that specifies an exact string
62
+ // gets an exact string in the shipped output, not an inference left to
63
+ // the reader.
64
+ function _hasAssumedEvidence(changedEntity) {
65
+ return changedEntity.changedFields.some((f) => f.after && typeof f.after === 'object' && f.after.evidenceGrade === 'assumed');
66
+ }
67
+
68
+ /**
69
+ * Compare `baseGraph` against `scenarioGraph` (either a real
70
+ * applyScenario({graph}) result, or another Scenario's own graph, for a
71
+ * scenario-vs-scenario comparison). Never throws — an empty/missing
72
+ * entity array on either side is treated as zero entities.
73
+ */
74
+ export function diffScenarioGraph(baseGraph, scenarioGraph) {
75
+ const nodeDiff = _diffKind(baseGraph.nodes ?? [], scenarioGraph.nodes ?? [], 'node');
76
+ const edgeDiff = _diffKind(baseGraph.edges ?? [], scenarioGraph.edges ?? [], 'edge');
77
+ const flowDiff = _diffKind(baseGraph.flows ?? [], scenarioGraph.flows ?? [], 'flow');
78
+ const changedEntities = [...nodeDiff.changed, ...edgeDiff.changed, ...flowDiff.changed]
79
+ .map((e) => (e.kind === 'edge' && _hasAssumedEvidence(e) ? { ...e, label: 'HYPOTHETICAL' } : e));
80
+ return {
81
+ changedEntities,
82
+ removedEntityIds: [...nodeDiff.removed, ...edgeDiff.removed, ...flowDiff.removed],
83
+ };
84
+ }