@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.
- package/CHANGELOG.md +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +379 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +841 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +144 -0
- package/src/shared/frontend-root.js +52 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// obligation-mapping.js — Milestone 4 sub-project 6a: the ObligationMapping
|
|
2
|
+
// extension contract (FR-504 §7.12, PRD §10.10's own field list).
|
|
3
|
+
//
|
|
4
|
+
// A PURE schema/validation module for ObligationMapping records. Zero
|
|
5
|
+
// imports, matching flow-grade.js's own "pure function library" precedent
|
|
6
|
+
// — this file has even less reason to import anything, since a record's
|
|
7
|
+
// shape check needs no graph traversal.
|
|
8
|
+
//
|
|
9
|
+
// ObligationMapping records are explicitly NOT DataFlowGraph v1 entities
|
|
10
|
+
// (PRD §10.10: extension records are "associated with, but not required
|
|
11
|
+
// inside" the immutable base graph) — never added to
|
|
12
|
+
// dataflow-graph.schema.json, never routed through validate.js's
|
|
13
|
+
// validateGraph(), never given a node:/edge:/flow:/data: canonical ID.
|
|
14
|
+
// See ids.js's obligationId() for the id scheme and its own header
|
|
15
|
+
// comment for why (mirrors provenanceNodeId/provenanceEdgeId's own
|
|
16
|
+
// precedent for "a real, stable-ID'd entity that deliberately is not a
|
|
17
|
+
// base-graph entity").
|
|
18
|
+
//
|
|
19
|
+
// The predicate/mapping ENGINE that actually produces real records from a
|
|
20
|
+
// real graph is a separate, later sub-project — this file only defines
|
|
21
|
+
// what a valid record looks like.
|
|
22
|
+
|
|
23
|
+
// PRD line 503-508's own six states.
|
|
24
|
+
export const OBLIGATION_STATES = Object.freeze([
|
|
25
|
+
'evidence_supported', 'gap_detected', 'unknown',
|
|
26
|
+
'manual_required', 'not_applicable', 'accepted_exception',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// PRD §10.10's cross-cutting fact-typing rule, applied to every extension
|
|
30
|
+
// contract, not just this one.
|
|
31
|
+
export const OBLIGATION_FACT_TYPES = Object.freeze([
|
|
32
|
+
'code_inferred', 'config_correlated', 'runtime_observed',
|
|
33
|
+
'declared', 'manual', 'hypothetical',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// FR-504's own applicability-inputs list (line 512): "entity role,
|
|
37
|
+
// jurisdiction, data subject, business process, merchant level, system
|
|
38
|
+
// scope, AI-system role... must be explicitly configured or marked
|
|
39
|
+
// unknown — never guessed from a field name." Every key defaults to
|
|
40
|
+
// null (== "not configured") rather than being omitted, so a record can
|
|
41
|
+
// never silently lack an input the PRD requires be shown.
|
|
42
|
+
export const APPLICABILITY_INPUT_KEYS = Object.freeze([
|
|
43
|
+
'entityRole', 'jurisdiction', 'dataSubject', 'businessProcess',
|
|
44
|
+
'merchantLevel', 'systemScope', 'aiSystemRole',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function _isNonEmptyString(v) {
|
|
48
|
+
return typeof v === 'string' && v.length > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _isStringOrNull(v) {
|
|
52
|
+
return v === null || v === undefined || typeof v === 'string';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _isStringArray(v) {
|
|
56
|
+
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Structural validation only — no cross-reference into any real graph
|
|
61
|
+
* (this module has zero graph access by design). Returns {valid, errors}
|
|
62
|
+
* — errors is an array of {path, message}, mirroring validate.js's own
|
|
63
|
+
* shape. Never throws.
|
|
64
|
+
*/
|
|
65
|
+
export function validateObligationMapping(record) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const err = (path, message) => errors.push({ path, message });
|
|
68
|
+
|
|
69
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
70
|
+
err('$', 'obligation mapping record must be an object');
|
|
71
|
+
return { valid: false, errors };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!_isNonEmptyString(record.id) || !record.id.startsWith('obligation:')) {
|
|
75
|
+
err('$.id', 'id is required and must start with "obligation:"');
|
|
76
|
+
}
|
|
77
|
+
if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
|
|
78
|
+
if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
|
|
79
|
+
if (!_isNonEmptyString(record.framework)) err('$.framework', 'framework is required');
|
|
80
|
+
if (!_isNonEmptyString(record.frameworkVersion)) err('$.frameworkVersion', 'frameworkVersion is required');
|
|
81
|
+
if (!_isNonEmptyString(record.requirementId)) err('$.requirementId', 'requirementId is required');
|
|
82
|
+
if (!_isStringOrNull(record.requirementSource)) err('$.requirementSource', 'requirementSource must be a string or null');
|
|
83
|
+
|
|
84
|
+
if (!record.applicabilityInputs || typeof record.applicabilityInputs !== 'object' || Array.isArray(record.applicabilityInputs)) {
|
|
85
|
+
err('$.applicabilityInputs', 'applicabilityInputs is required and must be an object');
|
|
86
|
+
} else {
|
|
87
|
+
for (const key of APPLICABILITY_INPUT_KEYS) {
|
|
88
|
+
if (!_isStringOrNull(record.applicabilityInputs[key])) {
|
|
89
|
+
err(`$.applicabilityInputs.${key}`, `applicabilityInputs.${key} must be a string or null`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!OBLIGATION_STATES.includes(record.state)) {
|
|
95
|
+
err('$.state', `unrecognized state "${record.state}" — must be one of ${OBLIGATION_STATES.join('|')}`);
|
|
96
|
+
}
|
|
97
|
+
if (!_isNonEmptyString(record.predicate)) err('$.predicate', 'predicate is required');
|
|
98
|
+
if (!OBLIGATION_FACT_TYPES.includes(record.factType)) {
|
|
99
|
+
err('$.factType', `unrecognized factType "${record.factType}" — must be one of ${OBLIGATION_FACT_TYPES.join('|')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!_isStringArray(record.contributingGraphIds ?? [])) err('$.contributingGraphIds', 'contributingGraphIds must be an array of strings');
|
|
103
|
+
if (!_isStringArray(record.evidence ?? [])) err('$.evidence', 'evidence must be an array of strings');
|
|
104
|
+
if (!_isStringArray(record.conflicts ?? [])) err('$.conflicts', 'conflicts must be an array of strings');
|
|
105
|
+
if (!_isStringArray(record.missingManualArtifacts ?? [])) err('$.missingManualArtifacts', 'missingManualArtifacts must be an array of strings');
|
|
106
|
+
|
|
107
|
+
if (!_isStringOrNull(record.reviewer)) err('$.reviewer', 'reviewer must be a string or null');
|
|
108
|
+
if (!_isStringOrNull(record.reviewedAt)) err('$.reviewedAt', 'reviewedAt must be a string or null');
|
|
109
|
+
if (!_isStringOrNull(record.expiresAt)) err('$.expiresAt', 'expiresAt must be a string or null');
|
|
110
|
+
|
|
111
|
+
// AC-28's own binding rule: PRD line 514 is explicit that
|
|
112
|
+
// evidence_supported means only "this predicate's evidence is
|
|
113
|
+
// supported," never organizational compliance — this module does NOT
|
|
114
|
+
// reject a record for having some null applicability inputs alongside
|
|
115
|
+
// evidence_supported (an input can be genuinely inapplicable to a
|
|
116
|
+
// given predicate). What IS enforced structurally: accepted_exception
|
|
117
|
+
// requires a real reviewer and expiresAt — an exception with no owner
|
|
118
|
+
// or no expiry is exactly the silent-permanent-waiver failure mode
|
|
119
|
+
// this state exists to prevent from being invisible.
|
|
120
|
+
if (record.state === 'accepted_exception') {
|
|
121
|
+
if (!_isNonEmptyString(record.reviewer)) err('$.reviewer', 'accepted_exception requires a reviewer');
|
|
122
|
+
if (!_isNonEmptyString(record.expiresAt)) err('$.expiresAt', 'accepted_exception requires an expiresAt');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { valid: errors.length === 0, errors };
|
|
126
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// obligation-predicates.js — Milestone 4 sub-project 6b: the graph-fact
|
|
2
|
+
// predicate evaluator + ObligationMapping record builder (FR-504 §7.12).
|
|
3
|
+
//
|
|
4
|
+
// A `graph-flow` predicate spec is a small declarative match object,
|
|
5
|
+
// mirroring dataflow/catalog.js's own established `match`-object +
|
|
6
|
+
// hand-written-matcher pattern (this codebase's convention for "a small
|
|
7
|
+
// declarative query, evaluated by a hand-written function," rather than a
|
|
8
|
+
// general query language) — {type:'graph-flow', dataClass, sinkKind,
|
|
9
|
+
// dimension, requiredVerdict}. No pre-joined "enriched flow" view exists
|
|
10
|
+
// on a shipped DataFlowGraph v1 document, so evaluateGraphFlowPredicate
|
|
11
|
+
// builds its own id->entity Maps and joins flow.dataElementIds/.sink
|
|
12
|
+
// against dataElements[]/nodes[], exactly as graph-builder.js's own
|
|
13
|
+
// internal loops already do.
|
|
14
|
+
//
|
|
15
|
+
// buildObligationMappingFromGraphPredicate mints a real ObligationMapping
|
|
16
|
+
// record (sub-project 6a) from an evaluation result. State derivation
|
|
17
|
+
// (scoping doc ruling 4, REVISED — see the "worst case wins" note below,
|
|
18
|
+
// found by the final whole-branch review): graph absent -> unknown;
|
|
19
|
+
// predicate matches zero relevant flows -> not_applicable; at least one
|
|
20
|
+
// relevant flow is genuinely, ASSESSED-and-failing -> gap_detected; no
|
|
21
|
+
// genuine failure but at least one relevant flow was never assessed ->
|
|
22
|
+
// unknown; every relevant flow was assessed and cleared ->
|
|
23
|
+
// evidence_supported. applicabilityInputs stays all-null (ruling 5 — no
|
|
24
|
+
// operator-config source for it exists anywhere in this codebase yet, a
|
|
25
|
+
// deliberately deferred, separate increment).
|
|
26
|
+
//
|
|
27
|
+
// WORST-CASE-WINS STATE DERIVATION (found by the final whole-branch
|
|
28
|
+
// review, reproduced live against this repo's own real
|
|
29
|
+
// bench/data-lineage/ corpus): the original version collapsed an
|
|
30
|
+
// UNASSESSED protection verdict (`not_assessed`/`unknown` — e.g. every
|
|
31
|
+
// external category other than external-api, which is the only one
|
|
32
|
+
// transit-protection.js actually evaluates) into "failed the requirement"
|
|
33
|
+
// exactly the same as a genuine `unprotected` verdict. On the real
|
|
34
|
+
// corpus this produced a false `gap_detected` — a HIPAA transmission-
|
|
35
|
+
// security gap asserted with zero real evidence — for the ONE non-
|
|
36
|
+
// not_applicable case the predicate reached, and did so silently (no
|
|
37
|
+
// warning, `evidence: []`). This directly violated the same "missing
|
|
38
|
+
// evidence must read as unknown/not_assessed, never a verdict" rule
|
|
39
|
+
// this codebase already leans on for AC-06 (`at-rest-protection.test.js`)
|
|
40
|
+
// and `docs/OSCAL.md`'s own "an unassessed control gets no finding" rule.
|
|
41
|
+
// Fixed by tracking assessed-vs-unassessed per flow and prioritizing a
|
|
42
|
+
// genuine failure > genuine unassessment > genuine success — mirroring
|
|
43
|
+
// `protection.js`'s own `aggregateVerdicts()` risk-precedence
|
|
44
|
+
// convention (the pessimistic answer wins), the established precedent
|
|
45
|
+
// this package already follows elsewhere (`flow-grade.js`'s `gradePath`,
|
|
46
|
+
// `graph-builder.js`'s `flow.protectionSummary` computation).
|
|
47
|
+
//
|
|
48
|
+
// KIND-VS-UNRESOLVED MATCHING (found by the same review, reproduced live
|
|
49
|
+
// against this repo's own AC-07 flagship fixture — PHI reaching an AI
|
|
50
|
+
// model provider): FR-203 (`coverage.js#resolveSiteDecision`) rewrites a
|
|
51
|
+
// sink node's `kind` to `'unresolved'` when its destination can't be
|
|
52
|
+
// statically resolved, retaining `subtype`/`category` — the highest-risk
|
|
53
|
+
// shape (a dynamic destination) was therefore silently excluded from
|
|
54
|
+
// this predicate's own sink-kind filter, reporting `not_applicable`
|
|
55
|
+
// ("this requirement does not apply") for exactly the case that most
|
|
56
|
+
// needs it to apply. Fixed by also matching `kind === 'unresolved'`, but
|
|
57
|
+
// (found by the fix round's own scoped re-review, also reproduced live)
|
|
58
|
+
// NOT unconditionally: FR-203 rewrites `kind: 'unresolved'` for `store`
|
|
59
|
+
// and `queue` sinks too (`coverage.js`'s `FR203_ELIGIBLE_KINDS`), so a
|
|
60
|
+
// bare `kind === 'unresolved'` OR-match let a purely local, in-process
|
|
61
|
+
// database write read as applicable to an `external`-scoped requirement
|
|
62
|
+
// — the exact same false-applicability failure mode as the bug just
|
|
63
|
+
// fixed, just moved one category over. The category is still recoverable
|
|
64
|
+
// via `sinkNode.subtype` (FR-203 retains it) and `CATEGORY_NODE_KIND`
|
|
65
|
+
// (`sink-registry.js`), the same table `graph-builder.js` used to assign
|
|
66
|
+
// the node's kind in the first place — so an unresolved sink now only
|
|
67
|
+
// matches when ITS OWN real category still maps to `spec.sinkKind`. Once
|
|
68
|
+
// matched, the worst-case-wins state derivation above correctly resolves
|
|
69
|
+
// it to `unknown` (an unresolved destination has no real transit verdict
|
|
70
|
+
// either), never a false `not_applicable` or a false `gap_detected`.
|
|
71
|
+
//
|
|
72
|
+
// EVIDENCE GAP, DISCLOSED (found by the same review): `evidence` is
|
|
73
|
+
// sourced from `edge.evidenceRefs`, but `graph-builder.js` hardcodes
|
|
74
|
+
// every real edge's `evidenceRefs` to `[]` and nothing downstream ever
|
|
75
|
+
// populates it — confirmed by grep, zero write sites beyond that one
|
|
76
|
+
// literal. So `record.evidence` is structurally always empty on any
|
|
77
|
+
// graph the real pipeline produces, including a genuine
|
|
78
|
+
// `evidence_supported` record. This is a real, upstream gap in
|
|
79
|
+
// `graph-builder.js` (no edge-level evidence producer exists for
|
|
80
|
+
// protection verdicts today), not something this module can close on
|
|
81
|
+
// its own — named here rather than silently shipped as if it worked.
|
|
82
|
+
//
|
|
83
|
+
// KNOWN LIMITATION: evaluateGraphFlowPredicate reads only
|
|
84
|
+
// flow.edgeIds[0] — a flow with multiple edges is evaluated using its
|
|
85
|
+
// first edge only, silently ignoring the rest. Not currently reachable:
|
|
86
|
+
// graph-builder.js (the only real producer of flows) always mints a
|
|
87
|
+
// single-element edgeIds array. This is a latent gap for any future
|
|
88
|
+
// multi-edge-flow producer, or a hand-built graph conforming to the
|
|
89
|
+
// schema (which places no length constraint on flow.edgeIds) — worth
|
|
90
|
+
// revisiting (e.g. worst-verdict-wins across all edges, mirroring
|
|
91
|
+
// aggregateVerdicts()'s own precedent) if that assumption ever changes.
|
|
92
|
+
//
|
|
93
|
+
// NEVER THROWS, defensively, not just by convention (found by the same
|
|
94
|
+
// review: 7 of 11 malformed-graph shapes tried threw a raw TypeError
|
|
95
|
+
// that propagated all the way out of auditor-walkthrough.js's
|
|
96
|
+
// evaluateFramework, losing every control's evaluation for the whole
|
|
97
|
+
// framework — a total compliance-report outage from one degraded
|
|
98
|
+
// graph). Every array field is defensively coerced via Array.isArray
|
|
99
|
+
// before use; a non-object entry inside an array is filtered out rather
|
|
100
|
+
// than dereferenced.
|
|
101
|
+
|
|
102
|
+
import { computeGraphDigest } from './export-json.js';
|
|
103
|
+
import { obligationId } from './ids.js';
|
|
104
|
+
import { CATEGORY_NODE_KIND } from './sink-registry.js';
|
|
105
|
+
|
|
106
|
+
function _asArray(v) {
|
|
107
|
+
return Array.isArray(v) ? v : [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function evaluateGraphFlowPredicate(spec, graph) {
|
|
111
|
+
const dataElementsById = new Map(_asArray(graph?.dataElements).filter(Boolean).map((d) => [d.id, d]));
|
|
112
|
+
const nodesById = new Map(_asArray(graph?.nodes).filter(Boolean).map((n) => [n.id, n]));
|
|
113
|
+
const edgesById = new Map(_asArray(graph?.edges).filter(Boolean).map((e) => [e.id, e]));
|
|
114
|
+
|
|
115
|
+
const relevantFlows = _asArray(graph?.flows).filter((f) => {
|
|
116
|
+
if (!f || typeof f !== 'object') return false;
|
|
117
|
+
const des = _asArray(f.dataElementIds).map((id) => dataElementsById.get(id)).filter(Boolean);
|
|
118
|
+
const hasClass = des.some((d) => _asArray(d.dataClasses).includes(spec.dataClass));
|
|
119
|
+
if (!hasClass) return false;
|
|
120
|
+
const sinkNode = nodesById.get(f.sink);
|
|
121
|
+
if (!sinkNode) return false;
|
|
122
|
+
// Also match 'unresolved', but only when the sink's OWN real category
|
|
123
|
+
// (subtype) still maps to spec.sinkKind — see the KIND-VS-UNRESOLVED
|
|
124
|
+
// MATCHING note above. A bare kind==='unresolved' OR-match would also
|
|
125
|
+
// admit unresolved store/queue sinks, which FR-203 rewrites the same
|
|
126
|
+
// way.
|
|
127
|
+
return sinkNode.kind === spec.sinkKind ||
|
|
128
|
+
(sinkNode.kind === 'unresolved' && CATEGORY_NODE_KIND[sinkNode.subtype] === spec.sinkKind);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (relevantFlows.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
applicable: false, matched: null, hasFailure: false, hasUnassessed: false,
|
|
134
|
+
contributingGraphIds: [], evidence: [], resultsCount: 0, failedCount: 0, notAssessedCount: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A verdict of 'not_assessed'/'unknown'/'not_applicable' means "never
|
|
139
|
+
// checked" (or "this dimension doesn't apply here"), never "checked and
|
|
140
|
+
// failed" — see the WORST-CASE-WINS STATE DERIVATION note above for why
|
|
141
|
+
// this distinction is load-bearing. 'not_applicable' is included
|
|
142
|
+
// defensively (found by the fix round's own scoped re-review): no real
|
|
143
|
+
// producer emits it on a `protection.<dimension>.verdict` today, but it
|
|
144
|
+
// IS a member of `protection.js`'s own `PROTECTION_VERDICTS`, and that
|
|
145
|
+
// module's own `_PRECEDENCE` ranks it below `protected` — treating it as
|
|
146
|
+
// a failure here would contradict the very precedent this worst-case-
|
|
147
|
+
// wins ordering is modeled on.
|
|
148
|
+
const UNASSESSED_VERDICTS = new Set(['not_assessed', 'unknown', 'not_applicable']);
|
|
149
|
+
const results = relevantFlows.map((f) => {
|
|
150
|
+
const edge = edgesById.get(_asArray(f.edgeIds)[0]);
|
|
151
|
+
const verdict = edge?.protection?.[spec.dimension]?.verdict ?? 'not_assessed';
|
|
152
|
+
const assessed = !UNASSESSED_VERDICTS.has(verdict);
|
|
153
|
+
return { flow: f, edge, verdict, assessed, cleared: verdict === spec.requiredVerdict };
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const hasFailure = results.some((r) => r.assessed && !r.cleared);
|
|
157
|
+
const hasUnassessed = results.some((r) => !r.assessed);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
applicable: true,
|
|
161
|
+
// Kept for backward compatibility with any caller reading the
|
|
162
|
+
// simple boolean — true only in the unambiguous "every relevant
|
|
163
|
+
// flow was assessed and cleared" case; buildObligationMappingFromGraphPredicate
|
|
164
|
+
// itself reads hasFailure/hasUnassessed directly for the real,
|
|
165
|
+
// worst-case-wins state derivation.
|
|
166
|
+
matched: !hasFailure && !hasUnassessed,
|
|
167
|
+
hasFailure,
|
|
168
|
+
hasUnassessed,
|
|
169
|
+
contributingGraphIds: results.map((r) => r.flow.id).filter((id) => typeof id === 'string'),
|
|
170
|
+
// Filtered to strings, matching the contributingGraphIds hardening
|
|
171
|
+
// just above and validateObligationMapping's own "evidence must be an
|
|
172
|
+
// array of strings" invariant (found by the fix round's own scoped
|
|
173
|
+
// re-review: a malformed edge.evidenceRefs entry used to reach the
|
|
174
|
+
// record unfiltered and fail validation downstream instead of here).
|
|
175
|
+
evidence: results.flatMap((r) => _asArray(r.edge?.evidenceRefs)).filter((e) => typeof e === 'string'),
|
|
176
|
+
resultsCount: results.length,
|
|
177
|
+
failedCount: results.filter((r) => r.assessed && !r.cleared).length,
|
|
178
|
+
notAssessedCount: results.filter((r) => !r.assessed).length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const _NULL_APPLICABILITY_INPUTS = Object.freeze({
|
|
183
|
+
entityRole: null, jurisdiction: null, dataSubject: null, businessProcess: null,
|
|
184
|
+
merchantLevel: null, systemScope: null, aiSystemRole: null,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export function buildObligationMappingFromGraphPredicate({
|
|
188
|
+
framework, frameworkVersion, requirementId, requirementSource, predicateLabel, graph, evaluation,
|
|
189
|
+
}) {
|
|
190
|
+
const graphId = graph?.graphId ?? null;
|
|
191
|
+
const graphDigest = graph ? computeGraphDigest(graph) : null;
|
|
192
|
+
|
|
193
|
+
// Found by this task's own review: a caller-contract violation (a
|
|
194
|
+
// truthy graph paired with a missing/null evaluation — i.e. forgetting
|
|
195
|
+
// to call evaluateGraphFlowPredicate first) used to throw here,
|
|
196
|
+
// inconsistent with this package's "public API never throws on
|
|
197
|
+
// malformed input" convention (obligation-mapping.js's own JSDoc,
|
|
198
|
+
// path-query.js, flow-grade.js). Not reachable through this sub-
|
|
199
|
+
// project's own documented calling convention (Task 2's wiring always
|
|
200
|
+
// pairs graph/evaluation correctly), but degrading to 'unknown' here
|
|
201
|
+
// — the same answer a genuinely absent graph gets — is honest and
|
|
202
|
+
// matches every sibling module's own never-throw contract.
|
|
203
|
+
//
|
|
204
|
+
// Worst-case-wins (found by the final whole-branch review — see the
|
|
205
|
+
// WORST-CASE-WINS STATE DERIVATION note at the top of this file): a
|
|
206
|
+
// genuine assessed failure always outranks a genuine unassessment,
|
|
207
|
+
// which always outranks a genuine clean pass.
|
|
208
|
+
let state;
|
|
209
|
+
if (!graph || !evaluation) state = 'unknown';
|
|
210
|
+
else if (!evaluation.applicable) state = 'not_applicable';
|
|
211
|
+
else if (evaluation.hasFailure) state = 'gap_detected';
|
|
212
|
+
else if (evaluation.hasUnassessed) state = 'unknown';
|
|
213
|
+
else state = 'evidence_supported';
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: obligationId({ framework, frameworkVersion, requirementId, graphId: graphId ?? '', graphDigest: graphDigest ?? '' }),
|
|
217
|
+
graphId: graphId ?? '(no graph)',
|
|
218
|
+
graphDigest: graphDigest ?? '(no graph)',
|
|
219
|
+
framework,
|
|
220
|
+
frameworkVersion,
|
|
221
|
+
requirementId,
|
|
222
|
+
requirementSource: requirementSource ?? null,
|
|
223
|
+
applicabilityInputs: { ..._NULL_APPLICABILITY_INPUTS },
|
|
224
|
+
state,
|
|
225
|
+
predicate: predicateLabel,
|
|
226
|
+
factType: 'code_inferred',
|
|
227
|
+
contributingGraphIds: evaluation?.contributingGraphIds ?? [],
|
|
228
|
+
evidence: evaluation?.evidence ?? [],
|
|
229
|
+
conflicts: [],
|
|
230
|
+
missingManualArtifacts: [],
|
|
231
|
+
reviewer: null,
|
|
232
|
+
reviewedAt: null,
|
|
233
|
+
expiresAt: null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// observation-adapters.js — the RuntimeObservation ADAPTER interface, plus
|
|
2
|
+
// the one shipped implementation (`native-jsonl`) (M5 deliverable #7,
|
|
3
|
+
// "Runtime-Corroborated Digital Twin" — the runtime-observed half only,
|
|
4
|
+
// "7b"; "7a", config-declared edges, is out of scope for this whole
|
|
5
|
+
// sub-project — see the scoping doc's §4.0). FR-505, AC-29. Pure by
|
|
6
|
+
// contract: zero imports (mirrors `runtime-observation.js`'s/
|
|
7
|
+
// `observation-correlation.js`'s/`flow-grade.js`'s own precedent, one step
|
|
8
|
+
// stricter even than `observation-correlation.js`'s single-file import
|
|
9
|
+
// list — see AD/6a), no fs, no I/O, never throws.
|
|
10
|
+
//
|
|
11
|
+
// ── Why only ONE adapter ships here ─────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// FR-505 itself names an open-ended adapter list: "beginning with approved
|
|
14
|
+
// OpenTelemetry traces/service graphs, gateway/mesh metadata, cloud flow
|
|
15
|
+
// metadata, and application-provided schema-safe events." The scoping
|
|
16
|
+
// doc's §4.3 ruling is to ship the INTERFACE plus a native, operator-
|
|
17
|
+
// authored JSONL adapter first, and defer OTLP to a second increment —
|
|
18
|
+
// not because OTLP is unimportant, but because an OTLP `resourceSpans`
|
|
19
|
+
// document carries an unbounded attribute surface (`http.url` with a live
|
|
20
|
+
// query string, `db.statement`, `messaging.message.payload`, arbitrary
|
|
21
|
+
// `gen_ai.*` prompt/completion attributes under active, ongoing extension
|
|
22
|
+
// by the OpenTelemetry semantic-conventions project). Mapping that surface
|
|
23
|
+
// safely means writing and defending an allowlist against an adversarial,
|
|
24
|
+
// EVOLVING external vocabulary — real, separately-scoped work with its
|
|
25
|
+
// own review, not something to bundle into the increment that also
|
|
26
|
+
// invents the RuntimeObservation contract (Task 1), the correlation match
|
|
27
|
+
// ladder (Task 2), and the import-keyed store (Task 4). This module exists
|
|
28
|
+
// so THAT increment is "write a second implementation of an
|
|
29
|
+
// already-proven interface," not "invent the interface and the mapping at
|
|
30
|
+
// the same time."
|
|
31
|
+
//
|
|
32
|
+
// ── Why external evidence arrives as a FILE, never live ingestion ──────
|
|
33
|
+
//
|
|
34
|
+
// FR-505's own no-egress rule, the root `CLAUDE.md`'s "no runtime cloud
|
|
35
|
+
// calls" convention, and `posture/runtime-correlation.js`'s own already-
|
|
36
|
+
// proven offline-file contract (an operator-supplied eBPF trace JSONL,
|
|
37
|
+
// read from disk, never pulled from a live collector by this codebase)
|
|
38
|
+
// all point the same direction: this module takes a TEXT STRING an
|
|
39
|
+
// operator has already exported and already has on disk, and returns
|
|
40
|
+
// drafts — it has no fetch, no socket, no fs call of any kind (AD/6a pins
|
|
41
|
+
// this structurally, not just by convention). The CLI that will eventually
|
|
42
|
+
// call this (a future task) is what reads the file; this module never
|
|
43
|
+
// does.
|
|
44
|
+
//
|
|
45
|
+
// ── Why the wire key set is CLOSED, exactly like the record it feeds ───
|
|
46
|
+
//
|
|
47
|
+
// `runtime-observation.js`'s own header explains at length why a
|
|
48
|
+
// RuntimeObservation is closed-world rather than open-world: it is built
|
|
49
|
+
// from operator-supplied telemetry this codebase never generated and
|
|
50
|
+
// cannot vouch for, and PRD line 983's own "reject fields capable of
|
|
51
|
+
// carrying payload values" requirement is a REJECT requirement, not a
|
|
52
|
+
// redact-and-hope one. That reasoning applies one layer further out, at
|
|
53
|
+
// the WIRE itself: `NATIVE_JSONL_RECORD_KEYS` is a closed allowlist of the
|
|
54
|
+
// only top-level JSON keys a native-JSONL record may carry, and any other
|
|
55
|
+
// top-level key — an attacker's `prompt`, an operator's well-meaning
|
|
56
|
+
// `note`, or (the smuggling attempt nobody would think to test on their
|
|
57
|
+
// own) a pre-declared `matchedNodeIds`/`matchConfidence` — is rejected,
|
|
58
|
+
// never ignored (AD/4c). `id`/`matchedNodeIds`/`matchedEdgeIds`/
|
|
59
|
+
// `matchedFlowIds`/`matchMethod`/`matchConfidence` are refused from the
|
|
60
|
+
// wire for a reason specific to THIS module: those six fields are
|
|
61
|
+
// minted/derived downstream (an id at import time, a match at correlation
|
|
62
|
+
// time) — if the wire could supply them directly, a compromised exporter,
|
|
63
|
+
// or an operator simply copy-pasting an already-matched record from one
|
|
64
|
+
// import into a hand-edited new one, could dictate what the graph BELIEVES
|
|
65
|
+
// was observed, bypassing the match ladder (`observation-correlation.js`,
|
|
66
|
+
// Task 2) entirely. None of the six are members of `NATIVE_JSONL_RECORD_KEYS`,
|
|
67
|
+
// so the SAME closed-world sweep that rejects an unrecognized field also
|
|
68
|
+
// rejects every one of these — there is no separate check to keep in sync
|
|
69
|
+
// (AD/4b, error 4).
|
|
70
|
+
//
|
|
71
|
+
// ── The two-layer rejection split (this module vs. `validateRuntimeObservation`) ──
|
|
72
|
+
//
|
|
73
|
+
// This module enforces the WIRE SHAPE only: `attributes` must be a plain
|
|
74
|
+
// object whose every value is a scalar (string/number/boolean) or an
|
|
75
|
+
// array of strings, no top-level key outside `NATIVE_JSONL_RECORD_KEYS`,
|
|
76
|
+
// and `eventCountBand`/`firstObservedAt`/`lastObservedAt` must each be a
|
|
77
|
+
// non-empty string. It does NOT validate WHICH attribute keys are
|
|
78
|
+
// approved — `validateRuntimeObservation` (Task 1, `runtime-observation.js`)
|
|
79
|
+
// is the single authority on that allowlist, and this module never
|
|
80
|
+
// duplicates it (a duplicated allowlist is exactly the kind of thing that
|
|
81
|
+
// silently drifts). Against the payload fixture, this means the adapter
|
|
82
|
+
// itself catches only 2 of the fixture's 4 smuggling attempts (line 3's
|
|
83
|
+
// top-level `prompt`, line 4's `matchedNodeIds`/`matchConfidence`) — the
|
|
84
|
+
// other two (line 1's `http.url` attribute key, line 2's `db.statement`
|
|
85
|
+
// attribute key) are structurally fine at the wire layer and are caught
|
|
86
|
+
// one layer up, by `validateRuntimeObservation`, at IMPORT time
|
|
87
|
+
// (`CLI/import-4`, a future task's own test — not a hole in this one).
|
|
88
|
+
// Any adapter error means the whole file yields nothing usable: this
|
|
89
|
+
// module always returns whatever drafts it produced regardless of
|
|
90
|
+
// `errors.length`, but the IMPORT command (a future task) refuses the
|
|
91
|
+
// file WHOLE the moment `errors.length > 0` — that refuse-the-whole-import
|
|
92
|
+
// behavior is that future task's own to prove, not this module's.
|
|
93
|
+
//
|
|
94
|
+
// ── The native-JSONL wire format, worked example ────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// One JSON object per line (no comments — JSONL admits none), each with
|
|
97
|
+
// up to 5 top-level keys (`NATIVE_JSONL_RECORD_KEYS`):
|
|
98
|
+
//
|
|
99
|
+
// {"environment":"production","attributes":{"destination.host":"api.stripe.com","destination.scheme":"https","tls.version":"1.3"},"eventCountBand":"101-1k","firstObservedAt":"2026-08-02T10:00:00.000Z","lastObservedAt":"2026-08-30T10:00:00.000Z"}
|
|
100
|
+
//
|
|
101
|
+
// `environment` is optional per line — when omitted, the record inherits
|
|
102
|
+
// the default environment of the surrounding call, `context.environment`
|
|
103
|
+
// (supplied by the CALLER, never by the file). `attributes` keys must be
|
|
104
|
+
// drawn from `RUNTIME_ATTRIBUTE_KEYS` (`runtime-observation.js`) — this
|
|
105
|
+
// module does not check that, but a key outside that list will be
|
|
106
|
+
// rejected one layer up, at import time. `eventCountBand` is a BAND
|
|
107
|
+
// (`RUNTIME_OBSERVATION_VERSION`'s sibling `EVENT_COUNT_BANDS`, e.g.
|
|
108
|
+
// `'1'`/`'2-10'`/`'11-100'`/`'101-1k'`/`'1k+'`), never a raw count.
|
|
109
|
+
// `firstObservedAt`/`lastObservedAt` are ISO-8601 date-times.
|
|
110
|
+
//
|
|
111
|
+
// ── `version` is caller-supplied, not read from `runtime-observation.js` ──
|
|
112
|
+
//
|
|
113
|
+
// This is precisely why this module has NO import at all, not even of
|
|
114
|
+
// `runtime-observation.js`'s own `RUNTIME_OBSERVATION_VERSION` constant:
|
|
115
|
+
// the CLI (a future task) passes that value in as `context.version`. A
|
|
116
|
+
// zero-import module can never itself drift from the contract it feeds —
|
|
117
|
+
// there is no cached/stale constant here to go stale.
|
|
118
|
+
|
|
119
|
+
// The 5 allowed top-level keys of a native-JSONL wire record. Any other
|
|
120
|
+
// top-level key — including every one of the six RuntimeObservation
|
|
121
|
+
// fields that are minted/derived downstream (`id`, `matchedNodeIds`,
|
|
122
|
+
// `matchedEdgeIds`, `matchedFlowIds`, `matchMethod`, `matchConfidence`) —
|
|
123
|
+
// is rejected by the closed-world sweep below, never ignored.
|
|
124
|
+
export const NATIVE_JSONL_RECORD_KEYS = Object.freeze([
|
|
125
|
+
'environment', 'attributes', 'eventCountBand', 'firstObservedAt', 'lastObservedAt',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
function _isPlainObject(v) {
|
|
129
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _isNonEmptyString(v) {
|
|
133
|
+
return typeof v === 'string' && v.length > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _isScalar(v) {
|
|
137
|
+
const t = typeof v;
|
|
138
|
+
return t === 'string' || t === 'number' || t === 'boolean';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The WIRE shape only — never which attribute keys are approved. A value
|
|
142
|
+
// must be a scalar, or an array of strings (mirroring
|
|
143
|
+
// `RUNTIME_ARRAY_ATTRIBUTE_KEYS`'s own shape one layer up, without this
|
|
144
|
+
// module knowing which specific key that applies to).
|
|
145
|
+
function _attributesShapeOk(attrs) {
|
|
146
|
+
if (!_isPlainObject(attrs)) return false;
|
|
147
|
+
for (const value of Object.values(attrs)) {
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
if (!value.every((x) => typeof x === 'string')) return false;
|
|
150
|
+
} else if (!_isScalar(value)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parses native-JSONL runtime-observation text into RuntimeObservation
|
|
159
|
+
* DRAFTS — every field a RuntimeObservation record needs EXCEPT `id`,
|
|
160
|
+
* `matchedNodeIds`, `matchedEdgeIds`, `matchedFlowIds`, `matchMethod`,
|
|
161
|
+
* `matchConfidence`, which are minted/derived downstream and can never be
|
|
162
|
+
* supplied by the wire. `context` is
|
|
163
|
+
* `{version, adapter, source, environment, windowStart, windowEnd, importedAt, retention}`
|
|
164
|
+
* — every draft carries these verbatim, EXCEPT `environment`, which a
|
|
165
|
+
* per-line `environment` key overrides.
|
|
166
|
+
*
|
|
167
|
+
* Never throws. `text` must be a string and `context` a plain object, or
|
|
168
|
+
* this returns `{drafts: [], errors: [{line: 0, message: '...'}]}`
|
|
169
|
+
* immediately. Blank lines (and the trailing newline JSONL files
|
|
170
|
+
* ordinarily end with) are skipped silently — never reported as errors.
|
|
171
|
+
* A malformed line (invalid JSON, wrong top-level shape, an unapproved
|
|
172
|
+
* top-level key, a malformed `attributes`/`eventCountBand`/
|
|
173
|
+
* `firstObservedAt`/`lastObservedAt`) yields exactly ONE `{line, message}`
|
|
174
|
+
* error for that line (`line` 1-based) and is skipped — every OTHER line
|
|
175
|
+
* still parses, so an operator sees every problem in one pass rather than
|
|
176
|
+
* stopping at the first.
|
|
177
|
+
*/
|
|
178
|
+
export function parseNativeJsonlObservations(text, context) {
|
|
179
|
+
if (typeof text !== 'string' || !_isPlainObject(context)) {
|
|
180
|
+
return {
|
|
181
|
+
drafts: [],
|
|
182
|
+
errors: [{ line: 0, message: 'parseNativeJsonlObservations requires a string text and a plain-object context' }],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const {
|
|
187
|
+
version, adapter, source, environment: defaultEnvironment,
|
|
188
|
+
windowStart, windowEnd, importedAt, retention,
|
|
189
|
+
} = context;
|
|
190
|
+
|
|
191
|
+
const drafts = [];
|
|
192
|
+
const errors = [];
|
|
193
|
+
|
|
194
|
+
const lines = text.split(/\r?\n/);
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const rawLine = lines[i];
|
|
197
|
+
if (rawLine.trim().length === 0) continue; // blank line, or the trailing newline's own empty tail
|
|
198
|
+
const line = i + 1;
|
|
199
|
+
|
|
200
|
+
let record;
|
|
201
|
+
try {
|
|
202
|
+
record = JSON.parse(rawLine);
|
|
203
|
+
} catch {
|
|
204
|
+
// M2 (final review): every OTHER rejection path in this module is
|
|
205
|
+
// key-only/value-free by design — V8's own JSON.parse error message
|
|
206
|
+
// quotes a snippet of the offending input (`e.message`), which would
|
|
207
|
+
// make this the sole value-echo channel in the whole command. Name
|
|
208
|
+
// only the line number, never the parse error's own text.
|
|
209
|
+
errors.push({ line, message: `invalid JSON on line ${line}` });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!_isPlainObject(record)) {
|
|
214
|
+
const gotType = record === null ? 'null' : Array.isArray(record) ? 'array' : typeof record;
|
|
215
|
+
errors.push({ line, message: `record must be a JSON object, got ${gotType}` });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const unknownKeys = Object.keys(record).filter((k) => !NATIVE_JSONL_RECORD_KEYS.includes(k));
|
|
220
|
+
if (unknownKeys.length > 0) {
|
|
221
|
+
errors.push({
|
|
222
|
+
line,
|
|
223
|
+
message: `unknown field(s) not permitted on the wire: ${unknownKeys.join(', ')} — native-JSONL records are closed-world (mirroring RuntimeObservation's own closed-world rule): only ${NATIVE_JSONL_RECORD_KEYS.join('/')} are accepted, and this rejects every one of id/matchedNodeIds/matchedEdgeIds/matchedFlowIds/matchMethod/matchConfidence too, since none are members of that allowlist`,
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!_attributesShapeOk(record.attributes)) {
|
|
229
|
+
errors.push({
|
|
230
|
+
line,
|
|
231
|
+
message: 'attributes must be a plain object whose every value is a scalar (string/number/boolean) or an array of strings',
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!_isNonEmptyString(record.eventCountBand)
|
|
237
|
+
|| !_isNonEmptyString(record.firstObservedAt)
|
|
238
|
+
|| !_isNonEmptyString(record.lastObservedAt)) {
|
|
239
|
+
errors.push({
|
|
240
|
+
line,
|
|
241
|
+
message: 'eventCountBand, firstObservedAt, and lastObservedAt must each be present and a non-empty string',
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
drafts.push({
|
|
247
|
+
version,
|
|
248
|
+
adapter,
|
|
249
|
+
source,
|
|
250
|
+
environment: 'environment' in record ? record.environment : defaultEnvironment,
|
|
251
|
+
windowStart,
|
|
252
|
+
windowEnd,
|
|
253
|
+
attributes: record.attributes,
|
|
254
|
+
eventCountBand: record.eventCountBand,
|
|
255
|
+
firstObservedAt: record.firstObservedAt,
|
|
256
|
+
lastObservedAt: record.lastObservedAt,
|
|
257
|
+
retention,
|
|
258
|
+
importedAt,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { drafts, errors };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The adapter registry — a frozen, module-level lookup. `adapterFor`
|
|
266
|
+
// returns `null` for anything unknown, NEVER a default: an unimplemented
|
|
267
|
+
// adapter name (e.g. `'otlp'`, deferred per the scoping doc's §4.3 ruling)
|
|
268
|
+
// must be rejected outright, not silently accepted on faith that it will
|
|
269
|
+
// someday exist.
|
|
270
|
+
const _ADAPTERS = Object.freeze({
|
|
271
|
+
'native-jsonl': Object.freeze({ name: 'native-jsonl', parse: parseNativeJsonlObservations }),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `adapterFor(name) -> {name, parse} | null` — the registry lookup.
|
|
276
|
+
* `parse` has the `(text, context) -> {drafts, errors}` signature of
|
|
277
|
+
* `parseNativeJsonlObservations` above.
|
|
278
|
+
*/
|
|
279
|
+
export function adapterFor(name) {
|
|
280
|
+
if (typeof name !== 'string') return null;
|
|
281
|
+
return Object.prototype.hasOwnProperty.call(_ADAPTERS, name) ? _ADAPTERS[name] : null;
|
|
282
|
+
}
|