@shieldaai/agent-map 0.0.0-stage → 0.1.0-alpha.3

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.
@@ -0,0 +1,24 @@
1
+ import { type Driver } from 'neo4j-driver';
2
+ import { type MapReadRequest, type MapStore, type MapView, type ReadScope } from './types.js';
3
+ export interface Neo4jMapStoreOptions {
4
+ readonly database?: string;
5
+ readonly timeoutMs?: number;
6
+ readonly maxSources?: number;
7
+ readonly maxNodes?: number;
8
+ readonly maxEdges?: number;
9
+ /** Host ceiling, also limited by the snapshot's enrolled cloud age limit. */
10
+ readonly policyMaxAgeSeconds?: number;
11
+ readonly clock?: () => number;
12
+ }
13
+ export declare class Neo4jMapStore implements MapStore {
14
+ #private;
15
+ constructor(driver: Driver, options?: Neo4jMapStoreOptions);
16
+ close(): Promise<void>;
17
+ read(input: ReadScope, request?: MapReadRequest): Promise<MapView>;
18
+ }
19
+ export interface Neo4jMapConnectionOptions extends Neo4jMapStoreOptions {
20
+ readonly uri: string;
21
+ readonly username: string;
22
+ readonly password: string;
23
+ }
24
+ export declare function createNeo4jMapStore(options: Neo4jMapConnectionOptions): Neo4jMapStore;
package/dist/store.js ADDED
@@ -0,0 +1,466 @@
1
+ import { isIP } from 'node:net';
2
+ import neo4j, {} from 'neo4j-driver';
3
+ import { immutableJson } from '@shieldaai/agent-guard';
4
+ import { MAP_LABELS, MAP_RELATIONSHIPS, MapQueryError, mapIdentifier, validateReadScope } from './types.js';
5
+ import { POLICY_COLUMNS, POLICY_QUERY, policyEvidence, policyParameters, policyPlan, safePolicyRow } from './policy-reader.js';
6
+ const CLOUD_FIELDS = ['cloud_status', 'cloud_reason', 'cloud_observed_at', 'cloud_assessment',
7
+ 'cloud_provider_label', 'cloud_provider_id', 'cloud_match_kind', 'cloud_node_updated',
8
+ 'cloud_ownership_updated', 'cloud_account_updated'];
9
+ const HOST_FIELDS = ['evidence_kind', 'assessment', 'manifest_observed_at'];
10
+ const FIELDS = Object.freeze({
11
+ GuardAgent: ['agent_id', 'name', 'owner_id', 'team_id', ...HOST_FIELDS],
12
+ GuardDeployment: ['deployment_id', 'environment', ...HOST_FIELDS],
13
+ GuardTask: ['task_id', 'principal_id', 'revoked', 'sensitive', 'allowed_recipients', 'allow_control_plane',
14
+ 'max_calls', 'max_concurrent', 'max_cost', 'deadline', 'calls', 'concurrent', 'spent', 'reserved'],
15
+ GuardPrincipal: ['principal_id'],
16
+ GuardGrant: ['task_id', 'tool', 'resources', 'evidence_kind', 'assessment'],
17
+ GuardTool: ['name', 'fingerprint', 'revision', 'registered', 'approved', 'known_metadata',
18
+ 'requires_approval', 'control_plane', 'max_cost'],
19
+ GuardResource: ['resource_id', 'tool', 'selector_kind'],
20
+ GuardResourceBinding: ['binding_id', 'tool', 'resource_ref', 'actions', 'scope', ...HOST_FIELDS],
21
+ GuardExecutionBinding: ['binding_id', 'tool', 'connection_id', 'identity_selection', ...HOST_FIELDS],
22
+ GuardExternalResource: ['provider', 'partition', 'account_id', 'region', 'arn', 'kind', ...HOST_FIELDS, ...CLOUD_FIELDS],
23
+ GuardExternalIdentity: ['provider', 'partition', 'account_id', 'region', 'arn', ...HOST_FIELDS, ...CLOUD_FIELDS],
24
+ GuardMcpConnection: ['connection_id', 'capture_status', 'unavailable_reason', 'capture_observed_at',
25
+ 'server_id', 'transport_ref', 'state', 'metadata_state', 'observed_at', 'evidence_kind', 'assessment'],
26
+ GuardMcpOperation: ['connection_id', 'name', 'kind', 'fingerprint', 'evidence_kind', 'assessment'],
27
+ });
28
+ const SOURCES = `MATCH (s:GuardSource)
29
+ WHERE s.company_id=$companyId AND s.tenant_id IN $tenantIds AND ($sourceId IS NULL OR s.id=$sourceId)
30
+ AND coalesce(s.lifecycle_status,'active')='active'
31
+ MATCH (p:GuardSnapshot {id:s.current_snapshot_id})
32
+ WHERE p.source_id=s.id AND p.status='published' AND
33
+ (($allTeams AND $agentId IS NULL) OR EXISTS {
34
+ MATCH (a:GuardAgent)-[:G_IN_SNAPSHOT]->(p)
35
+ WHERE a.source_id=s.id AND a.projection_id=p.id AND a.tenant_id=s.tenant_id
36
+ AND ($allTeams OR a.team_id IN $teamIds)
37
+ AND ($agentId IS NULL OR a.agent_id=$agentId OR a.entity_id=$agentId)
38
+ })
39
+ RETURN s.id AS id,s.tenant_id AS tenantId,s.native_source_id AS nativeSourceId,s.profile AS profile,
40
+ p.id AS currentId,p.snapshot_id AS currentSnapshotId,p.generated_at AS currentGeneratedAt,
41
+ substring(p.coverage_json,0,4097) AS currentCoverage,
42
+ s.previous_snapshot_id AS previousId,substring(p.cloud_coverage_json,0,16385) AS policyCoverage
43
+ ORDER BY s.id LIMIT $sourceLimit`;
44
+ const scoped = (variable) => `${variable}.source_id=$sourceId AND ${variable}.projection_id=$projectionId AND ${variable}.tenant_id=$tenantId`;
45
+ // Derive each next set only from previously authorized nodes, with bounds after every set.
46
+ const READ_ORDER = ['GuardAgent', 'GuardDeployment', 'GuardTask', 'GuardPrincipal',
47
+ 'GuardGrant', 'GuardExecutionBinding', 'GuardTool', 'GuardResource', 'GuardResourceBinding',
48
+ 'GuardExternalResource', 'GuardExternalIdentity', 'GuardMcpConnection', 'GuardMcpOperation'];
49
+ const historicalVisibility = (label, parameter) => `($projectionId=$currentId OR $allTeams OR n.entity_id IN $${parameter} OR NOT EXISTS {
50
+ MATCH (current:${label})-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$currentId})
51
+ WHERE current.entity_id=n.entity_id AND current.source_id=$sourceId
52
+ AND current.projection_id=$currentId AND current.tenant_id=$tenantId
53
+ })`;
54
+ function through(path, label) {
55
+ return `EXISTS { MATCH ${path} WHERE ${scoped('visible')} AND visible.id IN $${label}Ids }`;
56
+ }
57
+ const predicates = {
58
+ GuardAgent: `($allTeams OR n.team_id IN $teamIds) AND ($agentId IS NULL OR n.agent_id=$agentId OR n.entity_id=$agentId)
59
+ AND ${historicalVisibility('GuardAgent', 'currentAgentEntityIds')}`,
60
+ GuardDeployment: through('(n)-[:G_INSTANCE_OF]->(visible:GuardAgent)', 'GuardAgent'),
61
+ GuardTask: `${through('(n)-[:G_ASSIGNED_TO]->(visible:GuardDeployment)', 'GuardDeployment')}
62
+ AND ${historicalVisibility('GuardTask', 'currentTaskEntityIds')}`,
63
+ GuardPrincipal: through('(n)<-[:G_ACTS_AS]-(visible:GuardTask)', 'GuardTask'),
64
+ GuardGrant: through('(n)<-[:G_HAS_GRANT]-(visible:GuardTask)', 'GuardTask'),
65
+ GuardExecutionBinding: through('(n)-[:G_EXECUTES_FOR]->(visible:GuardDeployment)', 'GuardDeployment'),
66
+ GuardTool: `(${through('(n)<-[:G_FOR_TOOL]-(visible:GuardGrant)', 'GuardGrant')} OR
67
+ ${through('(n)<-[:G_EXECUTES_TOOL]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding')})`,
68
+ GuardResource: through('(n)<-[:G_TARGETS]-(visible:GuardGrant)', 'GuardGrant'),
69
+ GuardResourceBinding: through('(n)-[:G_BINDS_SELECTOR]->(visible:GuardResource)', 'GuardResource'),
70
+ GuardExternalResource: through('(n)<-[:G_MAPS_TO]-(visible:GuardResourceBinding)', 'GuardResourceBinding'),
71
+ GuardExternalIdentity: through('(n)<-[:G_USES_IDENTITY]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding'),
72
+ GuardMcpConnection: through('(n)<-[:G_VIA_CONNECTION]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding'),
73
+ GuardMcpOperation: `EXISTS {
74
+ MATCH (n)<-[:G_EXPOSES_OPERATION]-(c:GuardMcpConnection)<-[:G_VIA_CONNECTION]-(b:GuardExecutionBinding)
75
+ MATCH (b)-[:G_EXECUTES_TOOL]->(tool:GuardTool)<-[:G_OPERATION_TOOL]-(n)
76
+ WHERE ${['c', 'b', 'tool'].map(scoped).join(' AND ')}
77
+ AND c.id IN $GuardMcpConnectionIds AND b.id IN $GuardExecutionBindingIds AND tool.id IN $GuardToolIds
78
+ }`,
79
+ };
80
+ // Labels/property names below are static source allowlists, never request-supplied strings.
81
+ // Separate bounded templates avoid the planner's combinatorial work for a large OR of paths.
82
+ const NODES = Object.fromEntries(MAP_LABELS.map(label => [label,
83
+ `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
84
+ MATCH (n:${label})-[:G_IN_SNAPSHOT]->(p)
85
+ WHERE ${scoped('n')} AND size([label IN labels(n) WHERE label IN $labels])=1 AND
86
+ (($allTeams AND $agentId IS NULL) OR (${predicates[label]}))
87
+ RETURN '${label}' AS label,n { .id,.entity_id,.source_id,.projection_id,${FIELDS[label].map(field => `.${field}`).join(',')} } AS node
88
+ ORDER BY n.id LIMIT $nodeLimit`]));
89
+ const EDGES = `MATCH (n)-[r]->(m)
90
+ WHERE n.id IN $nodeIds AND m.id IN $nodeIds AND type(r) IN $relationships
91
+ AND n.source_id=$sourceId AND m.source_id=$sourceId
92
+ AND n.projection_id=$projectionId AND m.projection_id=$projectionId
93
+ AND n.tenant_id=$tenantId AND m.tenant_id=$tenantId
94
+ RETURN n.id AS source,m.id AS target,type(r) AS kind ORDER BY source,target,kind LIMIT $edgeLimit`;
95
+ const PREVIOUS = `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
96
+ RETURN p.id AS id,p.snapshot_id AS snapshotId,p.generated_at AS generatedAt,
97
+ substring(p.coverage_json,0,4097) AS coverage LIMIT 1`;
98
+ function bound(value, fallback, maximum) {
99
+ const number = value === undefined ? fallback : value;
100
+ if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < 1 || number > maximum)
101
+ throw new MapQueryError('MAP_REQUEST_INVALID');
102
+ return number;
103
+ }
104
+ function text(value) { return mapIdentifier(value, 'MAP_UNAVAILABLE'); }
105
+ function converted(value) {
106
+ if (neo4j.isInt(value)) {
107
+ if (!value.inSafeRange())
108
+ throw new MapQueryError('MAP_UNAVAILABLE');
109
+ return value.toNumber();
110
+ }
111
+ if (Array.isArray(value))
112
+ return value.map(converted);
113
+ if (value && typeof value === 'object')
114
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, converted(child)]));
115
+ return value;
116
+ }
117
+ function safeNode(raw, label, sourceId, projectionId) {
118
+ if (!MAP_LABELS.includes(label) || !raw || typeof raw !== 'object' || Array.isArray(raw))
119
+ throw new MapQueryError('MAP_UNAVAILABLE');
120
+ const data = raw;
121
+ if (data.source_id !== sourceId || data.projection_id !== projectionId)
122
+ throw new MapQueryError('MAP_UNAVAILABLE');
123
+ const properties = {};
124
+ for (const field of FIELDS[label])
125
+ if (Object.hasOwn(data, field))
126
+ properties[field] = converted(data[field]);
127
+ return { id: text(data.id), entityId: text(data.entity_id), sourceId, projectionId, label: label,
128
+ properties: immutableJson(properties, 4, 50_000, 512 * 1024) };
129
+ }
130
+ function observationTime(value) {
131
+ if (typeof value !== 'string' || !/^(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?Z$/.test(value))
132
+ return undefined;
133
+ const time = Date.parse(value);
134
+ return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? time : undefined;
135
+ }
136
+ function coreCaptureTimes(raw) {
137
+ // Older or incomplete graph metadata has unknown capture age. Recomposition
138
+ // changes generated_at, so it is never a safe substitute for these timestamps.
139
+ if (raw === null || raw === undefined)
140
+ return {};
141
+ try {
142
+ if (typeof raw !== 'string' || Buffer.byteLength(raw, 'utf8') > 4096)
143
+ throw new Error();
144
+ const parsed = JSON.parse(raw);
145
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
146
+ throw new Error();
147
+ const result = {};
148
+ for (const component of ['tasks', 'tools']) {
149
+ const item = parsed[component];
150
+ if (!item || typeof item !== 'object' || Array.isArray(item))
151
+ throw new Error();
152
+ const record = item;
153
+ if (Object.keys(record).sort().join(',') !== 'observedAt,status' || record.status !== 'complete' ||
154
+ observationTime(record.observedAt) === undefined)
155
+ throw new Error();
156
+ result[component] = record.observedAt;
157
+ }
158
+ return result;
159
+ }
160
+ catch {
161
+ throw new MapQueryError('MAP_UNAVAILABLE');
162
+ }
163
+ }
164
+ function coverage(nodes, captures, profile, summary, now) {
165
+ const connections = nodes.filter(node => node.label === 'GuardMcpConnection');
166
+ const observed = connections.filter(node => node.properties.capture_status === 'observed').length;
167
+ const references = nodes.filter(node => node.label === 'GuardExternalResource' || node.label === 'GuardExternalIdentity');
168
+ const captured = references.filter(node => typeof node.properties.cloud_status === 'string');
169
+ const resolved = captured.filter(node => node.properties.cloud_status === 'resolved').length;
170
+ const at = (values) => {
171
+ const times = values.map(value => typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN)
172
+ .filter(value => Number.isFinite(value) && value >= 0 && value <= 253_402_300_799_999);
173
+ return times.length ? { observedAt: new Date(Math.min(...times)).toISOString() } : {};
174
+ };
175
+ // Aggregate only visible connections. A new composition cannot renew a cached
176
+ // observation, and one missing/invalid/future timestamp must not disappear
177
+ // behind another fresh connection. Unavailable records have only capture time.
178
+ const mcpTimes = connections.flatMap(node => node.properties.capture_status === 'observed'
179
+ ? [node.properties.capture_observed_at, node.properties.observed_at]
180
+ : [node.properties.capture_observed_at]).map(observationTime);
181
+ const mcpCapture = mcpTimes.length && mcpTimes.every(time => time !== undefined && time <= now + 60_000)
182
+ ? { observedAt: new Date(Math.min(...mcpTimes)).toISOString() } : {};
183
+ return {
184
+ tasks: summary ? { status: 'not_collected' } : { status: 'complete', ...(captures.tasks ? { observedAt: captures.tasks } : {}) },
185
+ tools: summary ? { status: 'not_collected' } : { status: 'complete', ...(captures.tools ? { observedAt: captures.tools } : {}) },
186
+ manifest: profile === 'core' ? { status: 'not_collected' } : { status: summary ? 'partial' : 'complete', ...at(nodes.map(node => node.properties.manifest_observed_at)) },
187
+ mcp: summary || profile !== 'host-mcp' ? { status: 'not_collected' } :
188
+ { status: observed === connections.length ? 'complete' : observed ? 'partial' : 'unavailable', ...mcpCapture },
189
+ cloud: !captured.length ? { status: 'not_collected' } :
190
+ { status: resolved === references.length ? 'complete' : resolved ? 'partial' : 'unavailable', ...at(captured.map(node => node.properties.cloud_observed_at)) },
191
+ };
192
+ }
193
+ export class Neo4jMapStore {
194
+ #driver;
195
+ #database;
196
+ #timeout;
197
+ #sources;
198
+ #nodes;
199
+ #edges;
200
+ #policyAge;
201
+ #clock;
202
+ #active = 0;
203
+ constructor(driver, options = {}) {
204
+ this.#driver = driver;
205
+ this.#database = mapIdentifier(options.database ?? 'neo4j');
206
+ this.#timeout = bound(options.timeoutMs, 5000, 5000);
207
+ this.#sources = bound(options.maxSources, 64, 64);
208
+ this.#nodes = bound(options.maxNodes, 2000, 2000);
209
+ this.#edges = bound(options.maxEdges, 5000, 5000);
210
+ this.#policyAge = bound(options.policyMaxAgeSeconds, 7200, 86400);
211
+ if (options.clock !== undefined && typeof options.clock !== 'function')
212
+ throw new MapQueryError('MAP_REQUEST_INVALID');
213
+ this.#clock = options.clock ?? Date.now;
214
+ }
215
+ async close() { await this.#driver.close(); }
216
+ async read(input, request = {}) {
217
+ const scope = validateReadScope(input);
218
+ let selection;
219
+ try {
220
+ const copied = immutableJson(request, 2, 16, 32 * 1024);
221
+ if (!copied || Array.isArray(copied) || typeof copied !== 'object' ||
222
+ Object.keys(copied).some(key => !['sourceId', 'agentId', 'history', 'summary'].includes(key)))
223
+ throw new Error();
224
+ if (copied.sourceId !== undefined)
225
+ mapIdentifier(copied.sourceId);
226
+ if (copied.agentId !== undefined)
227
+ mapIdentifier(copied.agentId);
228
+ if (copied.history !== undefined && typeof copied.history !== 'boolean')
229
+ throw new Error();
230
+ if (copied.summary !== undefined && typeof copied.summary !== 'boolean')
231
+ throw new Error();
232
+ selection = copied;
233
+ }
234
+ catch {
235
+ throw new MapQueryError('MAP_REQUEST_INVALID');
236
+ }
237
+ if (this.#active >= 8)
238
+ throw new MapQueryError('MAP_UNAVAILABLE');
239
+ this.#active++;
240
+ const deadline = Date.now() + this.#timeout;
241
+ let cancel = () => { };
242
+ const check = () => { if (Date.now() >= deadline)
243
+ throw new MapQueryError('MAP_TIMEOUT'); };
244
+ const work = (async () => {
245
+ const session = this.#driver.session({ database: this.#database, defaultAccessMode: neo4j.session.READ, fetchSize: 32 });
246
+ let transaction;
247
+ try {
248
+ transaction = session.beginTransaction({ timeout: this.#timeout });
249
+ const tx = transaction;
250
+ cancel = () => { void tx.rollback().catch(() => { }); };
251
+ const run = async (query, parameters, receive) => {
252
+ check();
253
+ const records = [];
254
+ for await (const record of tx.run(query, parameters)) {
255
+ check();
256
+ if (receive)
257
+ receive(record);
258
+ else
259
+ records.push(record);
260
+ }
261
+ check();
262
+ return records;
263
+ };
264
+ const common = { companyId: scope.companyId, tenantIds: [...scope.tenantIds], teamIds: [...scope.teamIds],
265
+ allTeams: scope.allTeams, sourceId: selection.sourceId ?? null, agentId: selection.agentId ?? null,
266
+ sourceLimit: neo4j.int(this.#sources + 1) };
267
+ const selected = await run(SOURCES, common);
268
+ if (selected.length > this.#sources)
269
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
270
+ let nodeCount = 0;
271
+ let edgeCount = 0;
272
+ let materializedBytes = 0;
273
+ const charge = (value) => {
274
+ materializedBytes += Buffer.byteLength(JSON.stringify(value), 'utf8');
275
+ if (materializedBytes > 4 * 1024 * 1024)
276
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
277
+ };
278
+ const now = () => {
279
+ let stamp;
280
+ try {
281
+ stamp = this.#clock();
282
+ }
283
+ catch {
284
+ throw new MapQueryError('MAP_UNAVAILABLE');
285
+ }
286
+ if (!Number.isSafeInteger(stamp) || stamp < 0 || stamp > 253_402_300_799_999)
287
+ throw new MapQueryError('MAP_UNAVAILABLE');
288
+ return Math.floor(stamp / 1000);
289
+ };
290
+ let policyPairCount = 0;
291
+ const policyChecks = [];
292
+ const readPolicy = async (plan, params, chargeRows) => {
293
+ if (!plan.probes.length)
294
+ return [];
295
+ const rows = [];
296
+ let readBytes = 0;
297
+ const pairs = policyParameters(plan);
298
+ if (Buffer.byteLength(JSON.stringify(pairs), 'utf8') > 4 * 1024 * 1024)
299
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
300
+ await run(POLICY_QUERY, { ...params, policyPairs: pairs, policyRowLimit: neo4j.int(plan.probes.length * 3 + 1) }, item => {
301
+ if (rows.length >= plan.probes.length * 3)
302
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
303
+ const raw = Object.fromEntries(POLICY_COLUMNS.map(column => [column, converted(item.get(column))]));
304
+ const safe = safePolicyRow(raw);
305
+ readBytes += Buffer.byteLength(JSON.stringify(safe), 'utf8');
306
+ if (readBytes > 4 * 1024 * 1024)
307
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
308
+ if (chargeRows)
309
+ charge(safe);
310
+ rows.push(safe);
311
+ });
312
+ // Sort only the bounded materialized rows. A server ORDER BY would
313
+ // expand malformed relationship fan-out before applying its LIMIT.
314
+ rows.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b), 'en-US'));
315
+ return rows;
316
+ };
317
+ const sources = [];
318
+ for (const record of selected) {
319
+ const sourceId = text(record.get('id'));
320
+ const tenantId = text(record.get('tenantId'));
321
+ const currentId = text(record.get('currentId'));
322
+ const profile = record.get('profile');
323
+ if (!scope.tenantIds.includes(tenantId) || !['core', 'host', 'host-mcp'].includes(profile))
324
+ throw new MapQueryError('MAP_UNAVAILABLE');
325
+ const readProjection = async (id, snapshotId, generatedAt, rawCoverage, currentNodes = []) => {
326
+ const captures = coreCaptureTimes(rawCoverage);
327
+ const params = { ...common, sourceId, tenantId, projectionId: id, currentId, labels: [...MAP_LABELS],
328
+ currentAgentEntityIds: currentNodes.filter(node => node.label === 'GuardAgent').map(node => node.entityId),
329
+ currentTaskEntityIds: currentNodes.filter(node => node.label === 'GuardTask').map(node => node.entityId) };
330
+ const nodes = [];
331
+ for (const label of selection.summary ? ['GuardAgent', 'GuardDeployment'] : READ_ORDER) {
332
+ const authorized = Object.fromEntries(MAP_LABELS.map(kind => [`${kind}Ids`, nodes.filter(node => node.label === kind).map(node => node.id)]));
333
+ await run(NODES[label], { ...params, ...authorized, nodeLimit: neo4j.int(this.#nodes - nodeCount + 1) }, item => {
334
+ if (++nodeCount > this.#nodes)
335
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
336
+ if (item.get('label') !== label)
337
+ throw new MapQueryError('MAP_UNAVAILABLE');
338
+ const node = safeNode(item.get('node'), label, sourceId, id);
339
+ charge(node);
340
+ nodes.push(node);
341
+ });
342
+ }
343
+ const nodeIds = nodes.map(node => node.id);
344
+ if (new Set(nodeIds).size !== nodes.length)
345
+ throw new MapQueryError('MAP_UNAVAILABLE');
346
+ const ids = new Set(nodeIds);
347
+ const edges = [];
348
+ await run(EDGES, { ...params, nodeIds, relationships: [...MAP_RELATIONSHIPS],
349
+ edgeLimit: neo4j.int(this.#edges - edgeCount + 1) }, item => {
350
+ if (++edgeCount > this.#edges)
351
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
352
+ const source = text(item.get('source')), target = text(item.get('target')), kind = item.get('kind');
353
+ if (!ids.has(source) || !ids.has(target) || !MAP_RELATIONSHIPS.includes(kind))
354
+ throw new MapQueryError('MAP_UNAVAILABLE');
355
+ const edge = { source, target, kind: kind };
356
+ charge(edge);
357
+ edges.push(edge);
358
+ });
359
+ const projection = { id, snapshotId, generatedAt, status: 'published', nodes, edges, coverage: coverage(nodes, captures, profile, selection.summary ?? false, now() * 1000) };
360
+ if (!selection.summary && !selection.history && id === currentId) {
361
+ const plan = policyPlan(scope.companyId, tenantId, nodes, edges, 512 - policyPairCount);
362
+ policyPairCount += plan.records.length;
363
+ let maximumAge = this.#policyAge;
364
+ const enrolled = record.get('policyCoverage');
365
+ if (enrolled !== null && enrolled !== undefined && plan.probes.length) {
366
+ try {
367
+ if (typeof enrolled !== 'string' || enrolled.length > 16_384)
368
+ throw new Error();
369
+ const parsed = JSON.parse(enrolled);
370
+ if (!parsed || typeof parsed !== 'object' || !Number.isSafeInteger(parsed.maxAgeSeconds) ||
371
+ parsed.maxAgeSeconds < 1 || parsed.maxAgeSeconds > 604800)
372
+ throw new Error();
373
+ maximumAge = Math.min(maximumAge, parsed.maxAgeSeconds);
374
+ }
375
+ catch {
376
+ throw new MapQueryError('MAP_UNAVAILABLE');
377
+ }
378
+ }
379
+ const rows = await readPolicy(plan, params, true);
380
+ const evidence = policyEvidence(plan, rows, now(), maximumAge);
381
+ for (const item of evidence)
382
+ charge(item);
383
+ const enriched = { ...projection, policyEvidence: evidence };
384
+ policyChecks.push({ plan, params, rows, projection: enriched, maximumAge });
385
+ return enriched;
386
+ }
387
+ return projection;
388
+ };
389
+ const current = await readProjection(currentId, text(record.get('currentSnapshotId')), text(record.get('currentGeneratedAt')), record.get('currentCoverage'));
390
+ let previous = null;
391
+ if (selection.history && record.get('previousId')) {
392
+ const previousId = text(record.get('previousId'));
393
+ const previousRecords = await run(PREVIOUS, { sourceId, projectionId: previousId });
394
+ if (previousRecords.length === 1) {
395
+ const item = previousRecords[0];
396
+ previous = await readProjection(previousId, text(item.get('snapshotId')), text(item.get('generatedAt')), item.get('coverage'), current.nodes);
397
+ }
398
+ }
399
+ sources.push({ id: sourceId, tenantId, sourceId: text(record.get('nativeSourceId')), profile, current, previous });
400
+ }
401
+ // Publication may advance while the read transaction is open. Never return mixed generations.
402
+ const after = await run(SOURCES, common);
403
+ const pointers = (items) => JSON.stringify(items.map(record => [record.get('id'), record.get('currentId'), record.get('previousId'), record.get('currentCoverage'), record.get('policyCoverage')]));
404
+ if (pointers(selected) !== pointers(after))
405
+ throw new MapQueryError('MAP_UNAVAILABLE');
406
+ for (const checked of policyChecks) {
407
+ const rows = await readPolicy(checked.plan, checked.params, false);
408
+ if (JSON.stringify(rows) !== JSON.stringify(checked.rows))
409
+ throw new MapQueryError('MAP_UNAVAILABLE');
410
+ const refreshed = policyEvidence(checked.plan, rows, now(), checked.maximumAge);
411
+ if (JSON.stringify(refreshed) !== JSON.stringify(checked.projection.policyEvidence))
412
+ throw new MapQueryError('MAP_UNAVAILABLE');
413
+ }
414
+ try {
415
+ return immutableJson({ sources }, 16, 200_000, 4 * 1024 * 1024);
416
+ }
417
+ catch {
418
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
419
+ }
420
+ }
421
+ catch (error) {
422
+ if (error instanceof MapQueryError)
423
+ throw error;
424
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
425
+ throw new MapQueryError(/TimedOut|Timeout/.test(code) ? 'MAP_TIMEOUT' : 'MAP_UNAVAILABLE');
426
+ }
427
+ finally {
428
+ if (transaction?.isOpen())
429
+ await transaction.rollback().catch(() => { });
430
+ await session.close().catch(() => { });
431
+ }
432
+ })();
433
+ void work.finally(() => { this.#active--; }).catch(() => { });
434
+ let timer;
435
+ const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { cancel(); reject(new MapQueryError('MAP_TIMEOUT')); }, this.#timeout); });
436
+ try {
437
+ return await Promise.race([work, timeout]);
438
+ }
439
+ finally {
440
+ if (timer)
441
+ clearTimeout(timer);
442
+ }
443
+ }
444
+ }
445
+ export function createNeo4jMapStore(options) {
446
+ try {
447
+ const uri = new URL(options.uri);
448
+ const host = uri.hostname.replace(/^\[|\]$/g, '');
449
+ const local = isIP(host) === 4 ? host.startsWith('127.') : host === '::1';
450
+ if (!['bolt:', 'bolt+s:', 'neo4j+s:'].includes(uri.protocol) || !host || uri.username || uri.password ||
451
+ uri.pathname || uri.search || uri.hash || (uri.port && (+uri.port < 1 || +uri.port > 65535)) ||
452
+ (uri.protocol === 'bolt:' && !local))
453
+ throw new Error();
454
+ mapIdentifier(options.username);
455
+ if (typeof options.password !== 'string' || !options.password || options.password.length > 8192)
456
+ throw new Error();
457
+ const driver = neo4j.driver(options.uri, neo4j.auth.basic(options.username, options.password), {
458
+ connectionTimeout: 5000, connectionAcquisitionTimeout: 5000, maxTransactionRetryTime: 0,
459
+ maxConnectionPoolSize: 8,
460
+ });
461
+ return new Neo4jMapStore(driver, options);
462
+ }
463
+ catch {
464
+ throw new MapQueryError('MAP_REQUEST_INVALID');
465
+ }
466
+ }
@@ -0,0 +1,11 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export declare function safeError(error: unknown): string;
3
+ export declare function headers(response: ServerResponse, size: number, mime?: string): void;
4
+ export declare function errorResponse(response: ServerResponse, code: string): void;
5
+ export declare const count: (request: IncomingMessage, name: string) => number;
6
+ export type Bucket = {
7
+ since: number;
8
+ count: number;
9
+ };
10
+ export declare function consume(bucket: Bucket, max: number, window: number): void;
11
+ export declare function readBody(request: IncomingMessage, max: number, signal: AbortSignal): Promise<Buffer>;
@@ -0,0 +1,70 @@
1
+ import { MapQueryError } from './types.js';
2
+ import { fail } from './boundary.js';
3
+ const CODES = new Map([
4
+ ['MAP_UNAUTHENTICATED', 401], ['MAP_REQUEST_DENIED', 403], ['MAP_NOT_FOUND', 404],
5
+ ['MAP_REQUEST_INVALID', 400], ['MAP_ARGUMENT_INVALID', 400], ['MAP_OPERATION_INVALID', 400],
6
+ ['MAP_SCOPE_INVALID', 403], ['MAP_LIMIT_EXCEEDED', 413], ['MAP_REQUEST_TOO_LARGE', 413],
7
+ ['MAP_RESPONSE_TOO_LARGE', 413], ['MAP_TIMEOUT', 408], ['MAP_BUSY', 503],
8
+ ['MAP_RATE_LIMITED', 429], ['MAP_UNAVAILABLE', 503],
9
+ ['MAP_SESSION_REQUIRED', 401], ['MAP_IDENTITY_INVALID', 401], ['MAP_IDENTITY_UNAVAILABLE', 503],
10
+ ]);
11
+ export function safeError(error) {
12
+ return error instanceof MapQueryError && CODES.has(error.code) ? error.code : 'MAP_UNAVAILABLE';
13
+ }
14
+ export function headers(response, size, mime = 'application/json') {
15
+ response.setHeader('Content-Type', mime);
16
+ response.setHeader('Content-Length', size);
17
+ response.setHeader('Cache-Control', 'no-store');
18
+ response.setHeader('X-Content-Type-Options', 'nosniff');
19
+ response.setHeader('Referrer-Policy', 'no-referrer');
20
+ response.setHeader('Connection', 'close');
21
+ response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
22
+ }
23
+ export function errorResponse(response, code) {
24
+ if (response.writableEnded || response.destroyed)
25
+ return;
26
+ if (response.headersSent) {
27
+ response.destroy();
28
+ return;
29
+ }
30
+ const body = Buffer.from(JSON.stringify({ error: code }));
31
+ headers(response, body.length);
32
+ if (code === 'MAP_UNAUTHENTICATED')
33
+ response.setHeader('WWW-Authenticate', 'Bearer realm="agent-guard-reference"');
34
+ response.writeHead(CODES.get(code) ?? 503);
35
+ response.end(body);
36
+ }
37
+ export const count = (request, name) => request.rawHeaders.filter((entry, i) => i % 2 === 0 && entry.toLowerCase() === name).length;
38
+ export function consume(bucket, max, window) {
39
+ const now = performance.now();
40
+ if (now - bucket.since >= window) {
41
+ bucket.since = now;
42
+ bucket.count = 0;
43
+ }
44
+ if (++bucket.count > max)
45
+ fail('MAP_RATE_LIMITED');
46
+ }
47
+ export function readBody(request, max, signal) {
48
+ return new Promise((resolve, reject) => {
49
+ let size = 0;
50
+ const chunks = [];
51
+ const cleanup = () => { request.off('data', data); request.off('end', end); request.off('error', aborted); request.off('aborted', aborted); signal.removeEventListener('abort', timeout); };
52
+ const stop = (code) => { cleanup(); request.pause(); reject(new MapQueryError(code)); };
53
+ const data = (chunk) => { size += chunk.length; if (size > max) {
54
+ stop('MAP_REQUEST_TOO_LARGE');
55
+ return;
56
+ } chunks.push(chunk); };
57
+ const end = () => { cleanup(); resolve(Buffer.concat(chunks, size)); };
58
+ const aborted = () => stop('MAP_REQUEST_INVALID');
59
+ const timeout = () => stop('MAP_TIMEOUT');
60
+ if (signal.aborted) {
61
+ timeout();
62
+ return;
63
+ }
64
+ request.on('data', data);
65
+ request.once('end', end);
66
+ request.once('error', aborted);
67
+ request.once('aborted', aborted);
68
+ signal.addEventListener('abort', timeout, { once: true });
69
+ });
70
+ }