@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
@@ -0,0 +1,302 @@
1
+ import { MAP_PROPERTIES } from '../projection-fields.js';
2
+ import { MAP_LABELS } from '../types.js';
3
+ import { coverage } from './nodes.js';
4
+ export const QUERY_ACCESS = Array.from({ length: 4 }, (_, mode) => ({
5
+ allTeams: Boolean(mode & 2),
6
+ agentSelected: Boolean(mode & 1),
7
+ }));
8
+ export function agentPredicate(variable, access) {
9
+ return ([
10
+ !access.allTeams ? `${variable}.team_id IN $teamIds` : '',
11
+ access.agentSelected ? `(${variable}.agent_id IN $agentIds OR ${variable}.entity_id IN $agentIds)` : '',
12
+ ]
13
+ .filter(Boolean)
14
+ .join(' AND ') || 'true');
15
+ }
16
+ // Only validated scope/selection flags choose these fixed templates. Avoid asking
17
+ // Neo4j to plan irrelevant administrative, selection and historical branches.
18
+ export const SOURCES = QUERY_ACCESS.map(access => [false, true].map(sourceSelected => `MATCH (s:GuardSource)
19
+ WHERE s.company_id=$companyId AND s.tenant_id IN $tenantIds ${sourceSelected ? 'AND s.id=$sourceId' : ''}
20
+ AND coalesce(s.lifecycle_status,'active')='active'
21
+ MATCH (p:GuardSnapshot {id:s.current_snapshot_id})
22
+ WHERE p.source_id=s.id AND p.status='published'
23
+ ${access.allTeams && !access.agentSelected
24
+ ? ''
25
+ : `AND EXISTS {
26
+ MATCH (a:GuardAgent)-[:G_IN_SNAPSHOT]->(p)
27
+ WHERE a.source_id=s.id AND a.projection_id=p.id AND a.tenant_id=s.tenant_id
28
+ AND ${agentPredicate('a', access)}
29
+ }`}
30
+ RETURN s.id AS id,s.tenant_id AS tenantId,s.native_source_id AS nativeSourceId,s.profile AS profile,
31
+ p.id AS currentId,p.snapshot_id AS currentSnapshotId,p.generated_at AS currentGeneratedAt,
32
+ substring(p.coverage_json,0,4097) AS currentCoverage,
33
+ s.previous_snapshot_id AS previousId,substring(p.cloud_coverage_json,0,16385) AS policyCoverage
34
+ ORDER BY s.id LIMIT $sourceLimit`));
35
+ export const scoped = (variable) => `${variable}.source_id=$sourceId AND ${variable}.projection_id=$projectionId AND ${variable}.tenant_id=$tenantId`;
36
+ // Derive each next set only from previously authorized nodes, with bounds after every set.
37
+ export const READ_ORDER = [
38
+ 'GuardAgent',
39
+ 'GuardDeployment',
40
+ 'GuardTask',
41
+ 'GuardPrincipal',
42
+ 'GuardGrant',
43
+ 'GuardExecutionBinding',
44
+ 'GuardTool',
45
+ 'GuardResource',
46
+ 'GuardResourceBinding',
47
+ 'GuardExternalResource',
48
+ 'GuardExternalIdentity',
49
+ 'GuardMcpConnection',
50
+ 'GuardMcpOperation',
51
+ 'GuardOwner',
52
+ 'GuardCredentialReference',
53
+ 'GuardDirectAccess',
54
+ ];
55
+ export const historicalVisibility = (label, parameter) => `(n.entity_id IN $${parameter} OR NOT EXISTS {
56
+ MATCH (current:${label})-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$currentId})
57
+ WHERE current.entity_id=n.entity_id AND current.source_id=$sourceId
58
+ AND current.projection_id=$currentId AND current.tenant_id=$tenantId
59
+ })`;
60
+ export function through(path, label) {
61
+ return `EXISTS { MATCH ${path} WHERE ${scoped('visible')} AND visible.id IN $${label}Ids }`;
62
+ }
63
+ export const predicates = {
64
+ GuardDeployment: through('(n)-[:G_INSTANCE_OF]->(visible:GuardAgent)', 'GuardAgent'),
65
+ GuardTask: through('(n)-[:G_ASSIGNED_TO]->(visible:GuardDeployment)', 'GuardDeployment'),
66
+ GuardPrincipal: through('(n)<-[:G_ACTS_AS]-(visible:GuardTask)', 'GuardTask'),
67
+ GuardGrant: through('(n)<-[:G_HAS_GRANT]-(visible:GuardTask)', 'GuardTask'),
68
+ GuardExecutionBinding: through('(n)-[:G_EXECUTES_FOR]->(visible:GuardDeployment)', 'GuardDeployment'),
69
+ GuardTool: `(${through('(n)<-[:G_FOR_TOOL]-(visible:GuardGrant)', 'GuardGrant')} OR
70
+ ${through('(n)<-[:G_EXECUTES_TOOL]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding')})`,
71
+ GuardResource: through('(n)<-[:G_TARGETS]-(visible:GuardGrant)', 'GuardGrant'),
72
+ GuardResourceBinding: through('(n)-[:G_BINDS_SELECTOR]->(visible:GuardResource)', 'GuardResource'),
73
+ GuardExternalResource: through('(n)<-[:G_MAPS_TO]-(visible:GuardResourceBinding)', 'GuardResourceBinding'),
74
+ GuardExternalIdentity: through('(n)<-[:G_USES_IDENTITY]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding'),
75
+ GuardMcpConnection: through('(n)<-[:G_VIA_CONNECTION]-(visible:GuardExecutionBinding)', 'GuardExecutionBinding'),
76
+ GuardOwner: `(${through('(n)<-[:G_OWNED_BY]-(visible:GuardAgent)', 'GuardAgent')} OR ${through('(n)<-[:G_RESOURCE_OWNED_BY]-(visible:GuardResourceBinding)', 'GuardResourceBinding')})`,
77
+ GuardCredentialReference: through('(n)<-[:G_USES_CREDENTIAL]-(visible:GuardDeployment)', 'GuardDeployment'),
78
+ GuardDirectAccess: through('(n)-[:G_DIRECT_ACCESS_FOR]->(visible:GuardDeployment)', 'GuardDeployment'),
79
+ GuardMcpOperation: `EXISTS {
80
+ MATCH (n)<-[:G_EXPOSES_OPERATION]-(c:GuardMcpConnection)<-[:G_VIA_CONNECTION]-(b:GuardExecutionBinding)
81
+ MATCH (b)-[:G_EXECUTES_TOOL]->(tool:GuardTool)<-[:G_OPERATION_TOOL]-(n)
82
+ WHERE ${['c', 'b', 'tool'].map(scoped).join(' AND ')}
83
+ AND c.id IN $GuardMcpConnectionIds AND b.id IN $GuardExecutionBindingIds AND tool.id IN $GuardToolIds
84
+ }`,
85
+ };
86
+ export function nodePredicate(label, access, historical) {
87
+ if (access.allTeams && !access.agentSelected)
88
+ return 'true';
89
+ const predicate = label === 'GuardAgent' ? agentPredicate('n', access) : predicates[label];
90
+ // The historical fence remains mandatory for team-scoped previous projections.
91
+ // A current projection (or an all-teams reader) bypassed it in the original query.
92
+ if (historical && !access.allTeams && (label === 'GuardAgent' || label === 'GuardTask')) {
93
+ return `(${predicate}) AND ${historicalVisibility(label, label === 'GuardAgent' ? 'currentAgentEntityIds' : 'currentTaskEntityIds')}`;
94
+ }
95
+ return predicate;
96
+ }
97
+ // Labels/property names below are static source allowlists, never request-supplied strings.
98
+ // Separate bounded templates avoid the planner's combinatorial work for a large OR of paths.
99
+ export const NODES = QUERY_ACCESS.map(access => [false, true].map(historical => Object.fromEntries(MAP_LABELS.map(label => [
100
+ label,
101
+ `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
102
+ MATCH (n:${label})-[:G_IN_SNAPSHOT]->(p)
103
+ WHERE ${scoped('n')} AND size([label IN labels(n) WHERE label IN $labels])=1 AND
104
+ (${nodePredicate(label, access, historical)})
105
+ RETURN '${label}' AS label,n { .id,.entity_id,.source_id,.projection_id,${MAP_PROPERTIES[label].map(field => `.${field}`).join(',')} } AS node
106
+ ORDER BY n.id LIMIT $nodeLimit`,
107
+ ]))));
108
+ // A boundary read (get_agent_access, page flags) also needs what the team-wide read would
109
+ // show the flags: the in-scope delegation ancestors of the agent's tasks (recipients are
110
+ // checked against every ancestor; read after the agent's own nodes, within what is left of
111
+ // the budget) and, for `*` grants, whether an in-scope binding of the tool is sensitive.
112
+ // Both follow the team-wide visibility rules, so a flag is the same in either read.
113
+ // Templates stay specialized per scope mode: an all-teams reader needs no team check.
114
+ // Delegation ancestors (at most 16 levels) of the selected agent's tasks. Anchored at the
115
+ // agent's deployments, so the work follows its own task chains, not every task in the graph.
116
+ // For a team reader every ancestor on the path must be in scope, so the walk stops at the
117
+ // first hidden one (where the flags stop too) and never counts tasks the reader can't see.
118
+ // Every node a boundary path passes through gets the same checks as the team-wide read:
119
+ // scoped to this source, projection and tenant, and carrying exactly one Map label.
120
+ export const checked = (variable) => `${scoped(variable)} AND size([label IN labels(${variable}) WHERE label IN $labels])=1`;
121
+ // A binding reachable from an agent in the reader's teams, every hop checked.
122
+ export const inScopeBinding = (binding) => `EXISTS {
123
+ MATCH (${binding})-[:G_BINDS_SELECTOR]->(r:GuardResource)<-[:G_TARGETS]-(g:GuardGrant)<-[:G_HAS_GRANT]-(t:GuardTask)
124
+ -[:G_ASSIGNED_TO]->(d:GuardDeployment)-[:G_INSTANCE_OF]->(a:GuardAgent)
125
+ WHERE ${['r', 'g', 't', 'd', 'a'].map(checked).join(' AND ')} AND a.team_id IN $teamIds
126
+ }`;
127
+ export const ancestorsQuery = (access) => `MATCH path=(visible:GuardDeployment)<-[:G_ASSIGNED_TO]-(child:GuardTask)-[:G_DELEGATED_FROM|G_DERIVED_FROM*1..16]->(a:GuardTask)
128
+ WHERE visible.id IN $GuardDeploymentIds AND ${checked('visible')} AND ${checked('child')}
129
+ AND ALL(t IN nodes(path)[2..] WHERE ${checked('t')}${access.allTeams
130
+ ? ''
131
+ : ` AND EXISTS {
132
+ MATCH (t)-[:G_ASSIGNED_TO]->(d:GuardDeployment)-[:G_INSTANCE_OF]->(ag:GuardAgent)
133
+ WHERE ${checked('d')} AND ${checked('ag')} AND ag.team_id IN $teamIds
134
+ }`})
135
+ RETURN DISTINCT a.id AS id LIMIT $nodeLimit`;
136
+ export const ANCESTORS = QUERY_ACCESS.map(ancestorsQuery);
137
+ // The ancestor task records themselves, once their ids are known and fit the budget.
138
+ export const ANCESTOR_TASKS = `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
139
+ MATCH (n:GuardTask)-[:G_IN_SNAPSHOT]->(p)
140
+ WHERE ${scoped('n')} AND n.id IN $ancestorTaskIds AND size([label IN labels(n) WHERE label IN $labels])=1
141
+ RETURN 'GuardTask' AS label,n { .id,.entity_id,.source_id,.projection_id,${MAP_PROPERTIES.GuardTask.map(field => `.${field}`).join(',')} } AS node
142
+ ORDER BY n.id LIMIT $nodeLimit`;
143
+ // For each tool the agent holds a `*` grant on: up to five in-scope bindings of that tool
144
+ // marked confidential or restricted. Asked instead of loading every binding of the tool.
145
+ export const wildcardQuery = (access) => `UNWIND $wildcardTools AS tool
146
+ CALL (tool) {
147
+ MATCH (rb:GuardResourceBinding)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})
148
+ WHERE ${checked('rb')}
149
+ AND rb.tool=tool AND rb.sensitivity IN ['confidential','restricted'] AND ${access.allTeams ? 'true' : inScopeBinding('rb')}
150
+ WITH rb ORDER BY rb.entity_id LIMIT 5
151
+ RETURN collect({id:substring(rb.entity_id,0,4097),sensitivity:rb.sensitivity}) AS bindings
152
+ }
153
+ RETURN tool, bindings ORDER BY tool`;
154
+ export const WILDCARD = QUERY_ACCESS.map(wildcardQuery);
155
+ // For a team-scoped reader: the S3 buckets that bindings reachable from an in-scope agent
156
+ // map to. Identity scope names only these; any other bucket (another team's, or one no
157
+ // binding maps) becomes a note without a name or a count.
158
+ export const IN_SCOPE_TARGETS = `MATCH (rb:GuardResourceBinding)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})
159
+ WHERE ${checked('rb')} AND ${inScopeBinding('rb')}
160
+ MATCH (rb)-[:G_MAPS_TO]->(x:GuardExternalResource)
161
+ WHERE ${checked('x')}
162
+ AND ((x.provider='host' AND x.host_resource_id IN $targetRefs) OR (x.provider='aws' AND x.arn STARTS WITH 'arn:aws:s3:::'))
163
+ RETURN DISTINCT substring(x.arn,0,2049) AS arn,substring(x.host_resource_id,0,72) AS host LIMIT 20001`;
164
+ // Whether any deployment or task lies outside the reader's read: a boolean, never a count.
165
+ export const HIDDEN_SCOPE = `RETURN EXISTS {
166
+ MATCH (n)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})
167
+ WHERE (n:GuardDeployment OR n:GuardTask) AND ${scoped('n')} AND NOT n.id IN $visibleIds
168
+ } AS hidden`;
169
+ // Discovery reads, for all-teams readers only: nothing here belongs to a team, so a team-scoped
170
+ // read is refused before any of these run. A task is declared when a manifest task binding
171
+ // assigns it to a deployment of a declared agent; every hop gets the usual checks.
172
+ export const declaredTask = (task) => `EXISTS {
173
+ MATCH (${task})-[:G_ASSIGNED_TO]->(dd:GuardDeployment)-[:G_INSTANCE_OF]->(da:GuardAgent)
174
+ WHERE ${checked('dd')} AND ${checked('da')}
175
+ }`;
176
+ export const declaredPrincipal = (principal) => `EXISTS {
177
+ MATCH (${principal})<-[:G_ACTS_AS]-(pt:GuardTask)
178
+ WHERE ${checked('pt')} AND ${declaredTask('pt')}
179
+ }`;
180
+ export const SNAPSHOT = `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})`;
181
+ // Counting stops one past the limit, so the work is bounded and "at least" is reported honestly.
182
+ export const UNDECLARED_COUNT_LIMIT = 10_000;
183
+ export const UNDECLARED_COUNTS = `${SNAPSHOT}
184
+ CALL (p) {
185
+ MATCH (t:GuardTask)-[:G_IN_SNAPSHOT]->(p)
186
+ WHERE ${checked('t')} AND NOT ${declaredTask('t')}
187
+ WITH t LIMIT $countLimit
188
+ RETURN count(t) AS tasks
189
+ }
190
+ CALL (p) {
191
+ MATCH (c:GuardPrincipal)-[:G_IN_SNAPSHOT]->(p)
192
+ WHERE ${checked('c')} AND NOT ${declaredPrincipal('c')}
193
+ WITH c LIMIT $countLimit
194
+ RETURN count(c) AS principals
195
+ }
196
+ RETURN tasks, principals`;
197
+ // One page of undeclared tasks. `parentDeclared` looks one hop up: a sub-task of a declared
198
+ // agent's task that only lacks its own binding should not read like an unknown caller.
199
+ export const UNDECLARED_TASKS = `${SNAPSHOT}
200
+ MATCH (t:GuardTask)-[:G_IN_SNAPSHOT]->(p)
201
+ WHERE ${checked('t')} AND NOT ${declaredTask('t')}
202
+ WITH t ORDER BY t.task_id, t.id SKIP $pageOffset LIMIT $pageLimit
203
+ RETURN substring(t.entity_id,0,4097) AS id,substring(t.task_id,0,4097) AS taskId,
204
+ substring(t.principal_id,0,4097) AS principalId,t.delegated AS delegated,t.derived AS derived,t.revoked AS revoked,
205
+ EXISTS {
206
+ MATCH (t)-[:G_DELEGATED_FROM|G_DERIVED_FROM]->(parent:GuardTask)
207
+ WHERE ${checked('parent')} AND ${declaredTask('parent')}
208
+ } AS parentDeclared,
209
+ [(t)-[:G_HAS_GRANT]->(g:GuardGrant) WHERE ${checked('g')} | substring(g.tool,0,4097)][..33] AS tools
210
+ ORDER BY taskId, id`;
211
+ // One page of caller principals none of whose tasks is declared, with a bounded task count.
212
+ export const UNDECLARED_PRINCIPALS = `${SNAPSHOT}
213
+ MATCH (c:GuardPrincipal)-[:G_IN_SNAPSHOT]->(p)
214
+ WHERE ${checked('c')} AND NOT ${declaredPrincipal('c')}
215
+ WITH c ORDER BY c.principal_id, c.id SKIP $pageOffset LIMIT $pageLimit
216
+ CALL (c) {
217
+ MATCH (c)<-[:G_ACTS_AS]-(t:GuardTask)
218
+ WHERE ${checked('t')}
219
+ WITH t LIMIT 1001
220
+ RETURN count(t) AS tasks
221
+ }
222
+ RETURN substring(c.entity_id,0,4097) AS id,substring(c.principal_id,0,4097) AS principalId,tasks
223
+ ORDER BY principalId, id`;
224
+ // The AWS collections enrolled for the reader's company and tenants: at most one per account.
225
+ export const AWS_COLLECTION_LIMIT = 64;
226
+ export const AWS_COLLECTION_COLUMNS = [
227
+ 'id',
228
+ 'tenantId',
229
+ 'partition',
230
+ 'accountId',
231
+ 'schemaVersion',
232
+ 'status',
233
+ 'capturedAt',
234
+ 'contentHash',
235
+ ];
236
+ export const AWS_COLLECTIONS = `MATCH (c:GuardAwsCollection)
237
+ WHERE c.company_id=$companyId AND c.tenant_id IN $tenantIds
238
+ RETURN substring(c.id,0,65) AS id,substring(c.tenant_id,0,4097) AS tenantId,substring(c.partition,0,17) AS partition,
239
+ substring(c.account_id,0,13) AS accountId,c.schema_version AS schemaVersion,substring(c.status,0,33) AS status,
240
+ c.captured_at AS capturedAt,substring(c.content_hash,0,65) AS contentHash
241
+ ORDER BY id LIMIT $collectionLimit`;
242
+ // ARNs that a declared agent's execution binding names in this projection. The manifest's
243
+ // ARN is the claim: it stands whether or not the cloud join resolved it.
244
+ export const CLAIM_LIMIT = 10_000;
245
+ export const CLAIMED_IDENTITIES = `MATCH (i:GuardExternalIdentity)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})
246
+ WHERE ${checked('i')} AND i.arn IS NOT NULL AND EXISTS {
247
+ MATCH (i)<-[:G_USES_IDENTITY]-(b:GuardExecutionBinding)-[:G_EXECUTES_FOR]->(d:GuardDeployment)-[:G_INSTANCE_OF]->(a:GuardAgent)
248
+ WHERE ${['b', 'd', 'a'].map(checked).join(' AND ')}
249
+ }
250
+ RETURN DISTINCT substring(i.arn,0,2049) AS arn LIMIT $claimLimit`;
251
+ // Whether some declared execution binding names no identity at all: it may use a listed one.
252
+ export const BINDINGS_WITHOUT_IDENTITY = `RETURN EXISTS {
253
+ MATCH (b:GuardExecutionBinding)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})
254
+ WHERE ${checked('b')} AND NOT EXISTS { MATCH (b)-[:G_USES_IDENTITY]->() }
255
+ } AS open`;
256
+ // Per collection: its roles and users, how many are unclaimed, and how many members do not
257
+ // carry the collection's own capture receipt (any such member makes the account unknown).
258
+ export const AWS_MEMBER_LIMIT = 50_000;
259
+ export const UNCLAIMED_COUNTS = `MATCH (c:GuardAwsCollection)-[m:G_AWS_OBSERVED]->(p:AWSPrincipal)
260
+ WHERE c.id=$collectionId AND c.company_id=$companyId AND c.tenant_id=$collectionTenantId
261
+ WITH c,m,p LIMIT $memberLimit
262
+ RETURN count(p) AS observed,
263
+ sum(CASE WHEN coalesce(p.arn IN $claimed,false) THEN 0 ELSE 1 END) AS unclaimed,
264
+ sum(CASE WHEN m.capture_hash=c.content_hash AND m.captured_at=c.captured_at AND p.lastupdated=c.captured_at
265
+ THEN 0 ELSE 1 END) AS inconsistent`;
266
+ export const UNCLAIMED_PAGE = `MATCH (c:GuardAwsCollection)-[:G_AWS_OBSERVED]->(p:AWSPrincipal)
267
+ WHERE c.id IN $collectionIds AND c.company_id=$companyId AND c.tenant_id IN $tenantIds
268
+ AND NOT coalesce(p.arn IN $claimed,false)
269
+ WITH c,p ORDER BY p.arn, c.id SKIP $pageOffset LIMIT $pageLimit
270
+ RETURN substring(c.id,0,65) AS collectionId,substring(p.arn,0,2049) AS arn,p:AWSRole AS role,p:AWSUser AS user,
271
+ substring(p.name,0,129) AS name,substring(p.path,0,513) AS path
272
+ ORDER BY arn, collectionId`;
273
+ // Start from the snapshot's unique id: an unlabelled `n.id IN $nodeIds` scans every node
274
+ // in the database, while snapshot membership bounds the work to this projection.
275
+ export const EDGES = `MATCH (n)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})
276
+ MATCH (n)-[r]->(m)
277
+ WHERE n.id IN $nodeIds AND m.id IN $nodeIds AND type(r) IN $relationships
278
+ AND n.source_id=$sourceId AND m.source_id=$sourceId
279
+ AND n.projection_id=$projectionId AND m.projection_id=$projectionId
280
+ AND n.tenant_id=$tenantId AND m.tenant_id=$tenantId
281
+ RETURN n.id AS source,m.id AS target,type(r) AS kind ORDER BY source,target,kind LIMIT $edgeLimit`;
282
+ export const PREVIOUS = `MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})
283
+ RETURN p.id AS id,p.snapshot_id AS snapshotId,p.generated_at AS generatedAt,
284
+ substring(p.coverage_json,0,4097) AS coverage LIMIT 1`;
285
+ // Hard ceilings for one read. Defaults stay at 2,000 nodes, 5,000 edges and 4 MiB; a host may
286
+ // raise them for a larger fleet, and every read is still one bounded transaction.
287
+ export const MAX_STORE_NODES = 10_000;
288
+ export const MAX_STORE_EDGES = 25_000;
289
+ export const MAX_STORE_BYTES = 16 * 1024 * 1024;
290
+ // list_owners needs only these labels. A team reader's bindings are visible through
291
+ // agent -> deployment -> task -> grant -> selector, so that chain is read too.
292
+ export const OWNER_INDEX = ['GuardAgent', 'GuardResourceBinding', 'GuardOwner'];
293
+ export const OWNER_INDEX_SCOPED = [
294
+ 'GuardAgent',
295
+ 'GuardDeployment',
296
+ 'GuardTask',
297
+ 'GuardGrant',
298
+ 'GuardResource',
299
+ 'GuardResourceBinding',
300
+ 'GuardOwner',
301
+ ];
302
+ export const POLICY_SELECTION_KEYS = ['taskId', 'tool', 'resourceRef', 'action', 'optional'];
@@ -0,0 +1,81 @@
1
+ import { type Transaction, type Record as GraphRecord } from 'neo4j-driver';
2
+ import type { IdentityScopePlan, PolicyPlan } from '../policy-reader.js';
3
+ import { type JsonObject, type MapProjection, type MapReadRequest, type ReadScope } from '../types.js';
4
+ /** The per-read limits a store was configured with. */
5
+ export interface StoreLimits {
6
+ readonly sources: number;
7
+ readonly nodes: number;
8
+ readonly edges: number;
9
+ readonly bytes: number;
10
+ /** JSON values the final copy may hold. */
11
+ readonly values: number;
12
+ /** Host ceiling on policy evidence age, in seconds. */
13
+ readonly policyAge: number;
14
+ }
15
+ /** Evidence read for one projection, kept so it can be read again and compared before answering. */
16
+ export interface PolicyCheck {
17
+ plan: PolicyPlan;
18
+ scopePlan: IdentityScopePlan;
19
+ params: Record<string, unknown>;
20
+ rows: JsonObject[];
21
+ scopeRows: JsonObject[];
22
+ readable: ReadonlySet<string> | null;
23
+ projection: MapProjection;
24
+ maximumAge: number;
25
+ scopeBudget: number;
26
+ }
27
+ export interface AncestorJob {
28
+ sourceId: string;
29
+ params: Record<string, unknown>;
30
+ }
31
+ /** Identity scope runs after the ancestor phase, so task liveness sees the ancestors. */
32
+ export interface ScopeJob {
33
+ sourceId: string;
34
+ check: PolicyCheck;
35
+ reach: boolean;
36
+ enrolled: unknown;
37
+ }
38
+ /**
39
+ * One read: its scope and selection, the open transaction, the deadline and the budgets every
40
+ * phase draws from. Phases (sources, projections, ancestors, identity scope, verification) are
41
+ * functions over this object, in the modules next to it.
42
+ */
43
+ export declare class StoreRead {
44
+ #private;
45
+ readonly scope: ReadScope;
46
+ readonly selection: MapReadRequest;
47
+ readonly limits: StoreLimits;
48
+ nodeCount: number;
49
+ edgeCount: number;
50
+ materializedBytes: number;
51
+ policyPairCount: number;
52
+ scopePairCount: number;
53
+ readonly ancestorJobs: AncestorJob[];
54
+ readonly policyChecks: PolicyCheck[];
55
+ readonly scopeJobs: ScopeJob[];
56
+ readonly agentSelected: boolean;
57
+ /**
58
+ * Boundary evidence (ancestors, `*` bindings, identity scope) for get_agent_access and the
59
+ * page-of-agents flags read; get_agent_inventory keeps its cheaper read.
60
+ */
61
+ readonly boundary: boolean;
62
+ /** Index into the per-scope query templates: all-teams or not, agent selected or not. */
63
+ readonly accessMode: number;
64
+ readonly common: Record<string, unknown>;
65
+ /** The source rows this read answers for; set by `selectSources`. */
66
+ selected: GraphRecord[];
67
+ constructor(scope: ReadScope, selection: MapReadRequest, limits: StoreLimits, clock: () => number, transaction: Transaction, deadline: number);
68
+ check(): void;
69
+ /** Runs one query inside the read's transaction, checking the deadline around every record. */
70
+ run(query: string, parameters: Record<string, unknown>, receive?: (record: GraphRecord) => void): Promise<GraphRecord[]>;
71
+ selectSources(): Promise<void>;
72
+ /** Publication may advance while the read transaction is open. Never return mixed generations. */
73
+ recheckSources(): Promise<void>;
74
+ /** Counts what the read materializes against its byte budget. */
75
+ charge(value: unknown): void;
76
+ /** The store clock, in whole seconds. */
77
+ now(): number;
78
+ /** The enrolled policy age bound, when a read has evidence to judge. */
79
+ maximumAgeFor(enrolled: unknown): number;
80
+ transactionOpen(): boolean;
81
+ }
@@ -0,0 +1,135 @@
1
+ import neo4j, {} from 'neo4j-driver';
2
+ import { MapQueryError } from '../types.js';
3
+ import { SOURCES } from './queries.js';
4
+ /**
5
+ * One read: its scope and selection, the open transaction, the deadline and the budgets every
6
+ * phase draws from. Phases (sources, projections, ancestors, identity scope, verification) are
7
+ * functions over this object, in the modules next to it.
8
+ */
9
+ export class StoreRead {
10
+ scope;
11
+ selection;
12
+ limits;
13
+ nodeCount = 0;
14
+ edgeCount = 0;
15
+ materializedBytes = 0;
16
+ policyPairCount = 0;
17
+ scopePairCount = 0;
18
+ ancestorJobs = [];
19
+ policyChecks = [];
20
+ scopeJobs = [];
21
+ agentSelected;
22
+ /**
23
+ * Boundary evidence (ancestors, `*` bindings, identity scope) for get_agent_access and the
24
+ * page-of-agents flags read; get_agent_inventory keeps its cheaper read.
25
+ */
26
+ boundary;
27
+ /** Index into the per-scope query templates: all-teams or not, agent selected or not. */
28
+ accessMode;
29
+ common;
30
+ /** The source rows this read answers for; set by `selectSources`. */
31
+ selected = [];
32
+ #sourceQuery;
33
+ #transaction;
34
+ #deadline;
35
+ #clock;
36
+ constructor(scope, selection, limits, clock, transaction, deadline) {
37
+ this.scope = scope;
38
+ this.selection = selection;
39
+ this.limits = limits;
40
+ this.agentSelected = selection.agentId !== undefined || selection.agentIds !== undefined;
41
+ this.boundary = selection.agentIds !== undefined || selection.boundary === true;
42
+ this.accessMode = (scope.allTeams ? 2 : 0) + (this.agentSelected ? 1 : 0);
43
+ this.#sourceQuery = SOURCES[this.accessMode][selection.sourceId !== undefined ? 1 : 0];
44
+ this.#transaction = transaction;
45
+ this.#deadline = deadline;
46
+ this.#clock = clock;
47
+ this.common = {
48
+ companyId: scope.companyId,
49
+ tenantIds: [...scope.tenantIds],
50
+ teamIds: [...scope.teamIds],
51
+ allTeams: scope.allTeams,
52
+ sourceId: selection.sourceId ?? null,
53
+ agentIds: selection.agentIds ?? (selection.agentId !== undefined ? [selection.agentId] : null),
54
+ boundary: this.boundary,
55
+ sourceLimit: neo4j.int(limits.sources + 1),
56
+ };
57
+ }
58
+ check() {
59
+ if (Date.now() >= this.#deadline)
60
+ throw new MapQueryError('MAP_TIMEOUT');
61
+ }
62
+ /** Runs one query inside the read's transaction, checking the deadline around every record. */
63
+ async run(query, parameters, receive) {
64
+ this.check();
65
+ const records = [];
66
+ for await (const record of this.#transaction.run(query, parameters)) {
67
+ this.check();
68
+ if (receive)
69
+ receive(record);
70
+ else
71
+ records.push(record);
72
+ }
73
+ this.check();
74
+ return records;
75
+ }
76
+ async selectSources() {
77
+ this.selected = await this.run(this.#sourceQuery, this.common);
78
+ if (this.selected.length > this.limits.sources)
79
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
80
+ }
81
+ /** Publication may advance while the read transaction is open. Never return mixed generations. */
82
+ async recheckSources() {
83
+ const pointers = (items) => JSON.stringify(items.map(record => [
84
+ record.get('id'),
85
+ record.get('currentId'),
86
+ record.get('previousId'),
87
+ record.get('currentCoverage'),
88
+ record.get('policyCoverage'),
89
+ ]));
90
+ if (pointers(this.selected) !== pointers(await this.run(this.#sourceQuery, this.common)))
91
+ throw new MapQueryError('MAP_UNAVAILABLE');
92
+ }
93
+ /** Counts what the read materializes against its byte budget. */
94
+ charge(value) {
95
+ this.materializedBytes += Buffer.byteLength(JSON.stringify(value), 'utf8');
96
+ if (this.materializedBytes > this.limits.bytes)
97
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
98
+ }
99
+ /** The store clock, in whole seconds. */
100
+ now() {
101
+ let stamp;
102
+ try {
103
+ stamp = this.#clock();
104
+ }
105
+ catch {
106
+ throw new MapQueryError('MAP_UNAVAILABLE');
107
+ }
108
+ if (!Number.isSafeInteger(stamp) || stamp < 0 || stamp > 253_402_300_799_999)
109
+ throw new MapQueryError('MAP_UNAVAILABLE');
110
+ return Math.floor(stamp / 1000);
111
+ }
112
+ /** The enrolled policy age bound, when a read has evidence to judge. */
113
+ maximumAgeFor(enrolled) {
114
+ if (enrolled === null || enrolled === undefined)
115
+ return this.limits.policyAge;
116
+ try {
117
+ if (typeof enrolled !== 'string' || enrolled.length > 16_384)
118
+ throw new Error();
119
+ const parsed = JSON.parse(enrolled);
120
+ if (!parsed ||
121
+ typeof parsed !== 'object' ||
122
+ !Number.isSafeInteger(parsed.maxAgeSeconds) ||
123
+ parsed.maxAgeSeconds < 1 ||
124
+ parsed.maxAgeSeconds > 604800)
125
+ throw new Error();
126
+ return Math.min(this.limits.policyAge, parsed.maxAgeSeconds);
127
+ }
128
+ catch {
129
+ throw new MapQueryError('MAP_UNAVAILABLE');
130
+ }
131
+ }
132
+ transactionOpen() {
133
+ return this.#transaction.isOpen();
134
+ }
135
+ }
@@ -0,0 +1,3 @@
1
+ import { type MapReadRequest, type ReadScope } from '../types.js';
2
+ /** The read request, copied and checked: one read mode at a time, with only the options that mode takes. */
3
+ export declare function validateSelection(scope: ReadScope, request: MapReadRequest): MapReadRequest;
@@ -0,0 +1,102 @@
1
+ import { immutableJson } from '@shieldaai/agent-guard/internal';
2
+ import { mapIdentifier, MapQueryError } from '../types.js';
3
+ import { POLICY_SELECTION_KEYS } from './queries.js';
4
+ /** The read request, copied and checked: one read mode at a time, with only the options that mode takes. */
5
+ export function validateSelection(scope, request) {
6
+ try {
7
+ const copied = immutableJson(request, 3, 128, 64 * 1024);
8
+ if (!copied ||
9
+ Array.isArray(copied) ||
10
+ typeof copied !== 'object' ||
11
+ Object.keys(copied).some(key => ![
12
+ 'sourceId',
13
+ 'agentId',
14
+ 'agentIds',
15
+ 'boundary',
16
+ 'history',
17
+ 'summary',
18
+ 'flags',
19
+ 'outsideGuard',
20
+ 'owners',
21
+ 'policy',
22
+ 'undeclared',
23
+ 'unclaimedIdentities',
24
+ 'page',
25
+ ].includes(key)))
26
+ throw new Error();
27
+ for (const key of ['flags', 'outsideGuard', 'owners', 'boundary', 'undeclared', 'unclaimedIdentities'])
28
+ if (copied[key] !== undefined && typeof copied[key] !== 'boolean')
29
+ throw new Error();
30
+ // `false` reads no policy evidence; an object narrows the pairs to what the caller shows.
31
+ if (copied.policy !== undefined && copied.policy !== false) {
32
+ const policy = copied.policy;
33
+ if (!policy ||
34
+ typeof policy !== 'object' ||
35
+ Array.isArray(policy) ||
36
+ Object.keys(policy).some(key => !POLICY_SELECTION_KEYS.includes(key)) ||
37
+ (policy.optional !== undefined && typeof policy.optional !== 'boolean'))
38
+ throw new Error();
39
+ for (const key of ['taskId', 'tool', 'resourceRef', 'action'])
40
+ if (policy[key] !== undefined)
41
+ mapIdentifier(policy[key]);
42
+ }
43
+ // One read mode at a time: summary, history, flags, outside-guard and discovery reads
44
+ // differ in what they load, and no caller needs two.
45
+ if (['summary', 'history', 'flags', 'outsideGuard', 'owners', 'boundary', 'undeclared', 'unclaimedIdentities'].filter(key => copied[key] === true).length > 1)
46
+ throw new Error();
47
+ // Discovery reads: all-teams readers only, never for one agent. Undeclared activity
48
+ // pages within one source; unclaimed identities span the reader's sources.
49
+ const discovery = copied.undeclared === true || copied.unclaimedIdentities === true;
50
+ if (discovery && (!scope.allTeams || copied.agentId !== undefined || copied.agentIds !== undefined))
51
+ throw new Error();
52
+ if (copied.unclaimedIdentities === true && copied.sourceId !== undefined)
53
+ throw new Error();
54
+ if (copied.page !== undefined) {
55
+ const page = copied.page;
56
+ const whole = (value, minimum, maximum) => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum;
57
+ if (!discovery ||
58
+ !page ||
59
+ typeof page !== 'object' ||
60
+ Array.isArray(page) ||
61
+ Object.keys(page).some(key => !['kind', 'offset', 'limit'].includes(key)) ||
62
+ !whole(page.offset, 0, 100_000) ||
63
+ !whole(page.limit, 1, 100) ||
64
+ (copied.undeclared === true
65
+ ? copied.sourceId === undefined || !['tasks', 'principals'].includes(page.kind)
66
+ : page.kind !== undefined))
67
+ throw new Error();
68
+ }
69
+ if (copied.outsideGuard === true && copied.agentId !== undefined)
70
+ throw new Error();
71
+ if (copied.owners === true && (copied.agentId !== undefined || copied.agentIds !== undefined))
72
+ throw new Error();
73
+ // A one-agent flags read must carry boundary evidence; the page read uses agentIds.
74
+ if (copied.flags === true && copied.agentId !== undefined)
75
+ throw new Error();
76
+ if (copied.boundary === true && copied.agentId === undefined)
77
+ throw new Error();
78
+ if (copied.sourceId !== undefined)
79
+ mapIdentifier(copied.sourceId);
80
+ if (copied.agentId !== undefined)
81
+ mapIdentifier(copied.agentId);
82
+ // A page of agents for a flags read: one to 100 ids, never together with agentId.
83
+ if (copied.agentIds !== undefined) {
84
+ if (!Array.isArray(copied.agentIds) ||
85
+ !copied.agentIds.length ||
86
+ copied.agentIds.length > 100 ||
87
+ copied.agentId !== undefined ||
88
+ copied.flags !== true)
89
+ throw new Error();
90
+ for (const id of copied.agentIds)
91
+ mapIdentifier(id);
92
+ }
93
+ if (copied.history !== undefined && typeof copied.history !== 'boolean')
94
+ throw new Error();
95
+ if (copied.summary !== undefined && typeof copied.summary !== 'boolean')
96
+ throw new Error();
97
+ return copied;
98
+ }
99
+ catch {
100
+ throw new MapQueryError('MAP_REQUEST_INVALID');
101
+ }
102
+ }
package/dist/store.d.ts CHANGED
@@ -2,10 +2,20 @@ import { type Driver } from 'neo4j-driver';
2
2
  import { type MapReadRequest, type MapStore, type MapView, type ReadScope } from './types.js';
3
3
  export interface Neo4jMapStoreOptions {
4
4
  readonly database?: string;
5
+ /** The read's transaction deadline: default and at most 5,000 ms. It does not grow with the limits below. */
5
6
  readonly timeoutMs?: number;
6
7
  readonly maxSources?: number;
8
+ /**
9
+ * Nodes one read may load across its sources: default 2,000, at most 10,000. Values above
10
+ * the default are accepted but have been exercised only against a fake driver, never against
11
+ * a Neo4j database: a host that raises this, `maxEdges` or `maxBytes` should measure its own
12
+ * reads against its own database, within the fixed 5-second deadline, before relying on them.
13
+ */
7
14
  readonly maxNodes?: number;
15
+ /** Edges one read may load across its sources: default 5,000, at most 25,000. */
8
16
  readonly maxEdges?: number;
17
+ /** Bytes one read may materialize: default 4 MiB, at most 16 MiB. Raise it together with `maxNodes`. */
18
+ readonly maxBytes?: number;
9
19
  /** Host ceiling, also limited by the snapshot's enrolled cloud age limit. */
10
20
  readonly policyMaxAgeSeconds?: number;
11
21
  readonly clock?: () => number;
@@ -14,6 +24,11 @@ export declare class Neo4jMapStore implements MapStore {
14
24
  #private;
15
25
  constructor(driver: Driver, options?: Neo4jMapStoreOptions);
16
26
  close(): Promise<void>;
27
+ /**
28
+ * One bounded, read-only transaction. The phases run in a fixed order: the source rows, then
29
+ * either a discovery read or each source's projections, delegation ancestors and identity
30
+ * scope; the sources are rechecked and the evidence read again before the view is returned.
31
+ */
17
32
  read(input: ReadScope, request?: MapReadRequest): Promise<MapView>;
18
33
  }
19
34
  export interface Neo4jMapConnectionOptions extends Neo4jMapStoreOptions {