@shieldaai/agent-map 0.1.0-alpha.2 → 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.
- package/README.md +653 -268
- package/dist/assets.js +16 -26
- package/dist/boundary-flags.d.ts +73 -0
- package/dist/boundary-flags.js +352 -0
- package/dist/boundary.d.ts +19 -2
- package/dist/boundary.js +75 -19
- package/dist/cli-entry.d.ts +13 -0
- package/dist/cli-entry.js +66 -0
- package/dist/cli.js +41 -44
- package/dist/common.d.ts +34 -0
- package/dist/common.js +94 -0
- package/dist/http.js +78 -51
- package/dist/identity-cli.js +94 -47
- package/dist/identity-http.js +226 -94
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/mcp.js +40 -5
- package/dist/oidc.d.ts +5 -0
- package/dist/oidc.js +121 -38
- package/dist/policy-reader.d.ts +56 -3
- package/dist/policy-reader.js +823 -110
- package/dist/projection-fields.d.ts +4 -0
- package/dist/projection-fields.js +144 -0
- package/dist/service/changes.d.ts +5 -0
- package/dist/service/changes.js +255 -0
- package/dist/service/context.d.ts +33 -0
- package/dist/service/context.js +2 -0
- package/dist/service/discovery.d.ts +6 -0
- package/dist/service/discovery.js +31 -0
- package/dist/service/explain.d.ts +12 -0
- package/dist/service/explain.js +126 -0
- package/dist/service/flags.d.ts +20 -0
- package/dist/service/flags.js +70 -0
- package/dist/service/inventory.d.ts +4 -0
- package/dist/service/inventory.js +62 -0
- package/dist/service/json.d.ts +8 -0
- package/dist/service/json.js +7 -0
- package/dist/service/operations/discovery.d.ts +5 -0
- package/dist/service/operations/discovery.js +90 -0
- package/dist/service/operations/explain-access.d.ts +3 -0
- package/dist/service/operations/explain-access.js +25 -0
- package/dist/service/operations/find-agents-with-access.d.ts +3 -0
- package/dist/service/operations/find-agents-with-access.js +120 -0
- package/dist/service/operations/get-agent-access.d.ts +3 -0
- package/dist/service/operations/get-agent-access.js +75 -0
- package/dist/service/operations/get-agent-inventory.d.ts +3 -0
- package/dist/service/operations/get-agent-inventory.js +42 -0
- package/dist/service/operations/get-changes.d.ts +3 -0
- package/dist/service/operations/get-changes.js +11 -0
- package/dist/service/operations/get-inventory-health.d.ts +3 -0
- package/dist/service/operations/get-inventory-health.js +81 -0
- package/dist/service/operations/get-resource-access.d.ts +3 -0
- package/dist/service/operations/get-resource-access.js +48 -0
- package/dist/service/operations/list-agents.d.ts +3 -0
- package/dist/service/operations/list-agents.js +92 -0
- package/dist/service/operations/list-owners-resources.d.ts +5 -0
- package/dist/service/operations/list-owners-resources.js +69 -0
- package/dist/service/ordering.d.ts +9 -0
- package/dist/service/ordering.js +47 -0
- package/dist/service/outside-guard.d.ts +68 -0
- package/dist/service/outside-guard.js +313 -0
- package/dist/service/policy.d.ts +26 -0
- package/dist/service/policy.js +299 -0
- package/dist/service/schemas.d.ts +16 -0
- package/dist/service/schemas.js +192 -0
- package/dist/service/summaries.d.ts +32 -0
- package/dist/service/summaries.js +247 -0
- package/dist/service/work.d.ts +33 -0
- package/dist/service/work.js +116 -0
- package/dist/service.d.ts +5 -5
- package/dist/service.js +110 -645
- package/dist/store/ancestors.d.ts +9 -0
- package/dist/store/ancestors.js +86 -0
- package/dist/store/discovery.d.ts +8 -0
- package/dist/store/discovery.js +288 -0
- package/dist/store/evidence.d.ts +12 -0
- package/dist/store/evidence.js +90 -0
- package/dist/store/identity-scope.d.ts +9 -0
- package/dist/store/identity-scope.js +71 -0
- package/dist/store/nodes.d.ts +24 -0
- package/dist/store/nodes.js +237 -0
- package/dist/store/projection.d.ts +19 -0
- package/dist/store/projection.js +242 -0
- package/dist/store/queries.d.ts +48 -0
- package/dist/store/queries.js +302 -0
- package/dist/store/read.d.ts +81 -0
- package/dist/store/read.js +135 -0
- package/dist/store/selection.d.ts +3 -0
- package/dist/store/selection.js +102 -0
- package/dist/store.d.ts +15 -0
- package/dist/store.js +102 -395
- package/dist/transport.d.ts +42 -2
- package/dist/transport.js +144 -16
- package/dist/types.d.ts +184 -4
- package/dist/types.js +52 -11
- package/package.json +32 -5
|
@@ -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 {
|