@shieldaai/agent-map 0.1.0-alpha.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +652 -272
  2. package/dist/assets.js +16 -26
  3. package/dist/boundary-flags.d.ts +73 -0
  4. package/dist/boundary-flags.js +352 -0
  5. package/dist/boundary.d.ts +18 -3
  6. package/dist/boundary.js +71 -21
  7. package/dist/cli-entry.d.ts +11 -0
  8. package/dist/cli-entry.js +48 -0
  9. package/dist/cli.js +40 -43
  10. package/dist/common.d.ts +34 -0
  11. package/dist/common.js +94 -0
  12. package/dist/http.js +78 -51
  13. package/dist/identity-cli.js +93 -46
  14. package/dist/identity-http.js +226 -94
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/mcp.js +40 -5
  18. package/dist/oidc.d.ts +5 -0
  19. package/dist/oidc.js +121 -38
  20. package/dist/policy-reader.d.ts +56 -3
  21. package/dist/policy-reader.js +823 -110
  22. package/dist/projection-fields.d.ts +4 -0
  23. package/dist/projection-fields.js +144 -0
  24. package/dist/service/changes.d.ts +5 -0
  25. package/dist/service/changes.js +255 -0
  26. package/dist/service/context.d.ts +33 -0
  27. package/dist/service/context.js +2 -0
  28. package/dist/service/discovery.d.ts +6 -0
  29. package/dist/service/discovery.js +31 -0
  30. package/dist/service/explain.d.ts +12 -0
  31. package/dist/service/explain.js +126 -0
  32. package/dist/service/flags.d.ts +20 -0
  33. package/dist/service/flags.js +70 -0
  34. package/dist/service/inventory.d.ts +4 -0
  35. package/dist/service/inventory.js +62 -0
  36. package/dist/service/json.d.ts +8 -0
  37. package/dist/service/json.js +7 -0
  38. package/dist/service/operations/discovery.d.ts +5 -0
  39. package/dist/service/operations/discovery.js +90 -0
  40. package/dist/service/operations/explain-access.d.ts +3 -0
  41. package/dist/service/operations/explain-access.js +25 -0
  42. package/dist/service/operations/find-agents-with-access.d.ts +3 -0
  43. package/dist/service/operations/find-agents-with-access.js +120 -0
  44. package/dist/service/operations/get-agent-access.d.ts +3 -0
  45. package/dist/service/operations/get-agent-access.js +75 -0
  46. package/dist/service/operations/get-agent-inventory.d.ts +3 -0
  47. package/dist/service/operations/get-agent-inventory.js +42 -0
  48. package/dist/service/operations/get-changes.d.ts +3 -0
  49. package/dist/service/operations/get-changes.js +11 -0
  50. package/dist/service/operations/get-inventory-health.d.ts +3 -0
  51. package/dist/service/operations/get-inventory-health.js +81 -0
  52. package/dist/service/operations/get-resource-access.d.ts +3 -0
  53. package/dist/service/operations/get-resource-access.js +48 -0
  54. package/dist/service/operations/list-agents.d.ts +3 -0
  55. package/dist/service/operations/list-agents.js +92 -0
  56. package/dist/service/operations/list-owners-resources.d.ts +5 -0
  57. package/dist/service/operations/list-owners-resources.js +69 -0
  58. package/dist/service/ordering.d.ts +9 -0
  59. package/dist/service/ordering.js +47 -0
  60. package/dist/service/outside-guard.d.ts +68 -0
  61. package/dist/service/outside-guard.js +313 -0
  62. package/dist/service/policy.d.ts +26 -0
  63. package/dist/service/policy.js +299 -0
  64. package/dist/service/schemas.d.ts +16 -0
  65. package/dist/service/schemas.js +192 -0
  66. package/dist/service/summaries.d.ts +32 -0
  67. package/dist/service/summaries.js +247 -0
  68. package/dist/service/work.d.ts +33 -0
  69. package/dist/service/work.js +116 -0
  70. package/dist/service.d.ts +5 -5
  71. package/dist/service.js +110 -645
  72. package/dist/store/ancestors.d.ts +9 -0
  73. package/dist/store/ancestors.js +86 -0
  74. package/dist/store/discovery.d.ts +8 -0
  75. package/dist/store/discovery.js +288 -0
  76. package/dist/store/evidence.d.ts +12 -0
  77. package/dist/store/evidence.js +90 -0
  78. package/dist/store/identity-scope.d.ts +9 -0
  79. package/dist/store/identity-scope.js +71 -0
  80. package/dist/store/nodes.d.ts +24 -0
  81. package/dist/store/nodes.js +237 -0
  82. package/dist/store/projection.d.ts +19 -0
  83. package/dist/store/projection.js +242 -0
  84. package/dist/store/queries.d.ts +48 -0
  85. package/dist/store/queries.js +302 -0
  86. package/dist/store/read.d.ts +81 -0
  87. package/dist/store/read.js +135 -0
  88. package/dist/store/selection.d.ts +3 -0
  89. package/dist/store/selection.js +102 -0
  90. package/dist/store.d.ts +15 -0
  91. package/dist/store.js +102 -395
  92. package/dist/transport.d.ts +42 -2
  93. package/dist/transport.js +144 -16
  94. package/dist/types.d.ts +184 -4
  95. package/dist/types.js +52 -11
  96. package/package.json +16 -3
package/dist/store.js CHANGED
@@ -1,422 +1,112 @@
1
- import { isIP } from 'node:net';
2
1
  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 {};
2
+ import { isIP } from 'node:net';
3
+ import { immutableJson } from '@shieldaai/agent-guard/internal';
4
+ import { integer, shortenScopeList } from './common.js';
5
+ import { loadAncestors } from './store/ancestors.js';
6
+ import { readDiscovery } from './store/discovery.js';
7
+ import { verifyEvidence } from './store/evidence.js';
8
+ import { loadIdentityScope } from './store/identity-scope.js';
9
+ import { readSources } from './store/projection.js';
10
+ import { MAX_STORE_BYTES, MAX_STORE_EDGES, MAX_STORE_NODES } from './store/queries.js';
11
+ import { StoreRead } from './store/read.js';
12
+ import { validateSelection } from './store/selection.js';
13
+ import { mapIdentifier, MapQueryError, validateReadScope, } from './types.js';
14
+ /**
15
+ * The view as the store returns it. The identity-scope bucket lists are optional detail: if
16
+ * the view does not fit with them they are dropped (their statuses stand; see
17
+ * `shortenScopeList`) before the read is refused.
18
+ */
19
+ function freezeView(sources, limits) {
20
+ const freeze = (items) => immutableJson({ sources: items }, 16, limits.values, limits.bytes);
141
21
  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;
22
+ return freeze(sources);
159
23
  }
160
24
  catch {
161
- throw new MapQueryError('MAP_UNAVAILABLE');
25
+ const bare = (records) => records.map(record => shortenScopeList(record, 0));
26
+ const lean = sources.map(item => ({
27
+ ...item,
28
+ current: {
29
+ ...item.current,
30
+ ...(item.current.identityScope ? { identityScope: bare(item.current.identityScope) } : {}),
31
+ ...(item.current.identityReach ? { identityReach: bare(item.current.identityReach) } : {}),
32
+ },
33
+ }));
34
+ try {
35
+ return freeze(lean);
36
+ }
37
+ catch {
38
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
39
+ }
162
40
  }
163
41
  }
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
42
  export class Neo4jMapStore {
194
43
  #driver;
195
44
  #database;
196
45
  #timeout;
197
- #sources;
198
- #nodes;
199
- #edges;
200
- #policyAge;
46
+ #limits;
201
47
  #clock;
202
48
  #active = 0;
203
49
  constructor(driver, options = {}) {
204
50
  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);
51
+ this.#database = mapIdentifier(options.database ?? 'neo4j', 'MAP_CONFIG_INVALID');
52
+ this.#timeout = integer(options.timeoutMs, 5000, 1, 5000);
53
+ const sources = integer(options.maxSources, 64, 1, 64);
54
+ const nodes = integer(options.maxNodes, 2000, 1, MAX_STORE_NODES);
55
+ const edges = integer(options.maxEdges, 5000, 1, MAX_STORE_EDGES);
56
+ const bytes = integer(options.maxBytes, 4 * 1024 * 1024, 1, MAX_STORE_BYTES);
57
+ this.#limits = {
58
+ sources,
59
+ nodes,
60
+ edges,
61
+ bytes,
62
+ // JSON values the final copy may hold: the long-standing 200,000, or what the configured
63
+ // node and edge limits need (about 30 values per node and 4 per edge).
64
+ values: Math.max(200_000, nodes * 30 + edges * 4),
65
+ policyAge: integer(options.policyMaxAgeSeconds, 7200, 1, 86400),
66
+ };
211
67
  if (options.clock !== undefined && typeof options.clock !== 'function')
212
- throw new MapQueryError('MAP_REQUEST_INVALID');
68
+ throw new MapQueryError('MAP_CONFIG_INVALID');
213
69
  this.#clock = options.clock ?? Date.now;
214
70
  }
215
- async close() { await this.#driver.close(); }
71
+ async close() {
72
+ await this.#driver.close();
73
+ }
74
+ /**
75
+ * One bounded, read-only transaction. The phases run in a fixed order: the source rows, then
76
+ * either a discovery read or each source's projections, delegation ancestors and identity
77
+ * scope; the sources are rechecked and the evidence read again before the view is returned.
78
+ */
216
79
  async read(input, request = {}) {
217
80
  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
- }
81
+ const selection = validateSelection(scope, request);
237
82
  if (this.#active >= 8)
238
83
  throw new MapQueryError('MAP_UNAVAILABLE');
239
84
  this.#active++;
240
85
  const deadline = Date.now() + this.#timeout;
241
86
  let cancel = () => { };
242
- const check = () => { if (Date.now() >= deadline)
243
- throw new MapQueryError('MAP_TIMEOUT'); };
244
87
  const work = (async () => {
245
- const session = this.#driver.session({ database: this.#database, defaultAccessMode: neo4j.session.READ, fetchSize: 32 });
88
+ const session = this.#driver.session({
89
+ database: this.#database,
90
+ defaultAccessMode: neo4j.session.READ,
91
+ fetchSize: 32,
92
+ });
246
93
  let transaction;
247
94
  try {
248
95
  transaction = session.beginTransaction({ timeout: this.#timeout });
249
96
  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');
97
+ cancel = () => {
98
+ void tx.rollback().catch(() => { });
277
99
  };
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
- }
100
+ const read = new StoreRead(scope, selection, this.#limits, this.#clock, tx, deadline);
101
+ await read.selectSources();
102
+ if (selection.undeclared === true || selection.unclaimedIdentities === true)
103
+ return await readDiscovery(read);
104
+ const sources = await readSources(read);
105
+ await loadAncestors(read, sources);
106
+ await loadIdentityScope(read, sources);
107
+ await read.recheckSources();
108
+ await verifyEvidence(read);
109
+ return freezeView(sources, this.#limits);
420
110
  }
421
111
  catch (error) {
422
112
  if (error instanceof MapQueryError)
@@ -430,9 +120,18 @@ export class Neo4jMapStore {
430
120
  await session.close().catch(() => { });
431
121
  }
432
122
  })();
433
- void work.finally(() => { this.#active--; }).catch(() => { });
123
+ void work
124
+ .finally(() => {
125
+ this.#active--;
126
+ })
127
+ .catch(() => { });
434
128
  let timer;
435
- const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { cancel(); reject(new MapQueryError('MAP_TIMEOUT')); }, this.#timeout); });
129
+ const timeout = new Promise((_resolve, reject) => {
130
+ timer = setTimeout(() => {
131
+ cancel();
132
+ reject(new MapQueryError('MAP_TIMEOUT'));
133
+ }, this.#timeout);
134
+ });
436
135
  try {
437
136
  return await Promise.race([work, timeout]);
438
137
  }
@@ -447,20 +146,28 @@ export function createNeo4jMapStore(options) {
447
146
  const uri = new URL(options.uri);
448
147
  const host = uri.hostname.replace(/^\[|\]$/g, '');
449
148
  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)) ||
149
+ if (!['bolt:', 'bolt+s:', 'neo4j+s:'].includes(uri.protocol) ||
150
+ !host ||
151
+ uri.username ||
152
+ uri.password ||
153
+ uri.pathname ||
154
+ uri.search ||
155
+ uri.hash ||
156
+ (uri.port && (+uri.port < 1 || +uri.port > 65535)) ||
452
157
  (uri.protocol === 'bolt:' && !local))
453
158
  throw new Error();
454
159
  mapIdentifier(options.username);
455
160
  if (typeof options.password !== 'string' || !options.password || options.password.length > 8192)
456
161
  throw new Error();
457
162
  const driver = neo4j.driver(options.uri, neo4j.auth.basic(options.username, options.password), {
458
- connectionTimeout: 5000, connectionAcquisitionTimeout: 5000, maxTransactionRetryTime: 0,
163
+ connectionTimeout: 5000,
164
+ connectionAcquisitionTimeout: 5000,
165
+ maxTransactionRetryTime: 0,
459
166
  maxConnectionPoolSize: 8,
460
167
  });
461
168
  return new Neo4jMapStore(driver, options);
462
169
  }
463
170
  catch {
464
- throw new MapQueryError('MAP_REQUEST_INVALID');
171
+ throw new MapQueryError('MAP_CONFIG_INVALID');
465
172
  }
466
173
  }
@@ -1,7 +1,18 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
1
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
2
2
  export declare function safeError(error: unknown): string;
3
+ /**
4
+ * The status of a timeout. 408 says the client was too slow to send its request; once the whole
5
+ * request has been received, the time was spent by the service or its database, which is 504.
6
+ */
7
+ export declare const timeoutStatus: (received: boolean) => number | undefined;
8
+ /**
9
+ * Answers a failed request with its safe code; nothing from the error itself is sent.
10
+ * `received`: the request body had been read in full when the failure happened.
11
+ */
12
+ export declare function respondError(response: ServerResponse, error: unknown, received?: boolean): void;
3
13
  export declare function headers(response: ServerResponse, size: number, mime?: string): void;
4
- export declare function errorResponse(response: ServerResponse, code: string): void;
14
+ /** `status` overrides the code's usual status, for a refusal HTTP names more exactly (417). */
15
+ export declare function errorResponse(response: ServerResponse, code: string, status?: number): void;
5
16
  export declare const count: (request: IncomingMessage, name: string) => number;
6
17
  export type Bucket = {
7
18
  since: number;
@@ -9,3 +20,32 @@ export type Bucket = {
9
20
  };
10
21
  export declare function consume(bucket: Bucket, max: number, window: number): void;
11
22
  export declare function readBody(request: IncomingMessage, max: number, signal: AbortSignal): Promise<Buffer>;
23
+ /** Per-credential or per-session rate limit: `maxRequests` in each `windowMs`. */
24
+ export declare function rateLimit(value: unknown, defaultMaxRequests: number): {
25
+ maxRequests: number;
26
+ windowMs: number;
27
+ };
28
+ /**
29
+ * The JSON body of an admitted POST. Call it only after the credential (or session and CSRF)
30
+ * and rate checks have passed: it is what tells a client that sent `Expect: 100-continue` to go
31
+ * on, and a refusal before it has already answered with its own status.
32
+ */
33
+ export declare function readJsonBody(request: IncomingMessage, response: ServerResponse, limit: number, signal: AbortSignal): Promise<unknown>;
34
+ /** The `{ operation, args }` envelope of `POST /v1/query`. */
35
+ export declare function queryEnvelope(parsed: unknown): {
36
+ operation: unknown;
37
+ args: unknown;
38
+ };
39
+ /** A body that must be the empty object, as the sign-in and sign-out routes require. */
40
+ export declare function emptyObject(parsed: unknown): void;
41
+ /** Sends one fixed viewer asset. */
42
+ export declare function sendAsset(response: ServerResponse, asset: {
43
+ body: Buffer;
44
+ mime: string;
45
+ }): void;
46
+ /**
47
+ * What neither server speaks: a request that arrives with `Expect: 100-continue` still goes
48
+ * through the normal handler, any other expectation is 417, and tunnels, upgrades and
49
+ * malformed requests are dropped.
50
+ */
51
+ export declare function refuseOtherTraffic(server: Server, handle: (request: IncomingMessage, response: ServerResponse) => Promise<void>): void;