@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.
- package/README.md +652 -272
- 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 +18 -3
- package/dist/boundary.js +71 -21
- package/dist/cli-entry.d.ts +11 -0
- package/dist/cli-entry.js +48 -0
- package/dist/cli.js +40 -43
- package/dist/common.d.ts +34 -0
- package/dist/common.js +94 -0
- package/dist/http.js +78 -51
- package/dist/identity-cli.js +93 -46
- 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 +16 -3
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { s3BucketName, shortenScopeList } from '../common.js';
|
|
2
|
+
import { MapQueryError, } from '../types.js';
|
|
3
|
+
import { jsonList, jsonObject } from './json.js';
|
|
4
|
+
import { compare, ordered } from './ordering.js';
|
|
5
|
+
import { deployments } from './summaries.js';
|
|
6
|
+
import { connected, number, QueryWork, string } from './work.js';
|
|
7
|
+
/** A declared target names a host resource; name it only when that resource is in the reader's scope. */
|
|
8
|
+
export function targetVisibility(projection, scope, work) {
|
|
9
|
+
if (scope.allTeams)
|
|
10
|
+
return () => true;
|
|
11
|
+
work.step(projection.nodes.length);
|
|
12
|
+
const visible = new Set([
|
|
13
|
+
...projection.nodes
|
|
14
|
+
.filter(node => node.label === 'GuardExternalResource')
|
|
15
|
+
.map(node => string(node, 'host_resource_id'))
|
|
16
|
+
.filter((id) => id !== null),
|
|
17
|
+
...(projection.inScopeTargets ?? []),
|
|
18
|
+
]);
|
|
19
|
+
return target => visible.has(target);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Host-declared paths that act without the guard, per deployment in the agent's focus.
|
|
23
|
+
* Manifest v3 sets `direct_access_status`; older manifests leave it absent, which reads as
|
|
24
|
+
* `unknown` and never as `none_declared`.
|
|
25
|
+
*/
|
|
26
|
+
export function directAccess(projection, graph, scope, work) {
|
|
27
|
+
const visibleTarget = targetVisibility(projection, scope, work);
|
|
28
|
+
return ordered(graph.nodes
|
|
29
|
+
.filter(node => node.label === 'GuardDeployment')
|
|
30
|
+
.map(deployment => {
|
|
31
|
+
const entries = connected(projection, new Set([deployment.id]), 'G_DIRECT_ACCESS_FOR', work, true);
|
|
32
|
+
const declared = string(deployment, 'direct_access_status');
|
|
33
|
+
// An entry without the v3 status would be inconsistent; report what was found.
|
|
34
|
+
const status = entries.length ? 'found' : declared === 'none_declared' ? 'none_declared' : 'unknown';
|
|
35
|
+
return {
|
|
36
|
+
id: deployment.entityId,
|
|
37
|
+
deploymentId: string(deployment, 'deployment_id'),
|
|
38
|
+
status,
|
|
39
|
+
evidenceKind: 'declared',
|
|
40
|
+
entries: ordered(entries.map(entry => {
|
|
41
|
+
const target = string(entry, 'target_ref');
|
|
42
|
+
const targetStatus = target === null ? 'none' : visibleTarget(target) ? 'visible' : 'outside_visible_scope';
|
|
43
|
+
return {
|
|
44
|
+
id: entry.entityId,
|
|
45
|
+
accessId: string(entry, 'access_id'),
|
|
46
|
+
kind: string(entry, 'kind'),
|
|
47
|
+
targetRef: targetStatus === 'visible' ? target : null,
|
|
48
|
+
targetStatus,
|
|
49
|
+
description: string(entry, 'description'),
|
|
50
|
+
};
|
|
51
|
+
})),
|
|
52
|
+
};
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** `data` with each identity-scope list cut to `keep` entries, or null if nothing would change. */
|
|
56
|
+
export function shortenIdentityScope(data, keep) {
|
|
57
|
+
const outside = data.outsideGuard;
|
|
58
|
+
const records = outside?.identityScope;
|
|
59
|
+
if (!Array.isArray(records))
|
|
60
|
+
return null;
|
|
61
|
+
const shortened = records.map(record => shortenScopeList(record, keep));
|
|
62
|
+
if (shortened.every((record, index) => record === records[index]))
|
|
63
|
+
return null;
|
|
64
|
+
return { ...data, outsideGuard: { ...outside, identityScope: jsonList(shortened) } };
|
|
65
|
+
}
|
|
66
|
+
export const reachIndex = new WeakMap();
|
|
67
|
+
/**
|
|
68
|
+
* Outside-guard reach over the whole visible projection: any declared direct path or any
|
|
69
|
+
* evaluated tool identity can touch any resource, so `none_found` needs every visible
|
|
70
|
+
* deployment to have declared its paths and every visible identity to be evaluated.
|
|
71
|
+
*/
|
|
72
|
+
export function outsideGuardIndex(projection, work) {
|
|
73
|
+
const existing = reachIndex.get(projection);
|
|
74
|
+
if (existing)
|
|
75
|
+
return existing;
|
|
76
|
+
work.step(projection.nodes.length);
|
|
77
|
+
let undeclaredDeployments = 0, untargetedPaths = 0, undeclaredIdentities = 0;
|
|
78
|
+
const identityAccounts = new Set();
|
|
79
|
+
const directByTarget = new Map();
|
|
80
|
+
// The tools each deployment has an execution binding for, read once per deployment.
|
|
81
|
+
const executed = new Map();
|
|
82
|
+
const executedTools = (deployment) => {
|
|
83
|
+
let tools = executed.get(deployment.id);
|
|
84
|
+
if (!tools) {
|
|
85
|
+
tools = new Set(connected(projection, new Set([deployment.id]), 'G_EXECUTES_FOR', work, true).map(item => string(item, 'tool')));
|
|
86
|
+
executed.set(deployment.id, tools);
|
|
87
|
+
}
|
|
88
|
+
return tools;
|
|
89
|
+
};
|
|
90
|
+
for (const node of projection.nodes) {
|
|
91
|
+
if (node.label === 'GuardDeployment') {
|
|
92
|
+
const status = string(node, 'direct_access_status');
|
|
93
|
+
// `found` with no entry in the read is inconsistent, so it is not a declaration either.
|
|
94
|
+
const entries = connected(projection, new Set([node.id]), 'G_DIRECT_ACCESS_FOR', work, true).length;
|
|
95
|
+
if (!(status === 'none_declared' || (status === 'found' && entries > 0)))
|
|
96
|
+
undeclaredDeployments++;
|
|
97
|
+
}
|
|
98
|
+
if (node.label === 'GuardDirectAccess') {
|
|
99
|
+
const target = string(node, 'target_ref');
|
|
100
|
+
if (target === null)
|
|
101
|
+
untargetedPaths++;
|
|
102
|
+
else {
|
|
103
|
+
const paths = directByTarget.get(target);
|
|
104
|
+
if (paths)
|
|
105
|
+
paths.push(node);
|
|
106
|
+
else
|
|
107
|
+
directByTarget.set(target, [node]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (node.label === 'GuardTask') {
|
|
111
|
+
// A granted tool runs under some identity on every deployment the task is assigned to;
|
|
112
|
+
// a deployment without an execution binding for it leaves that identity undeclared.
|
|
113
|
+
const granted = new Set(connected(projection, new Set([node.id]), 'G_HAS_GRANT', work).map(item => string(item, 'tool')));
|
|
114
|
+
if (granted.size) {
|
|
115
|
+
const deployments = connected(projection, new Set([node.id]), 'G_ASSIGNED_TO', work);
|
|
116
|
+
work.step(granted.size * deployments.length);
|
|
117
|
+
if (!deployments.length || deployments.some(item => [...granted].some(tool => !executedTools(item).has(tool))))
|
|
118
|
+
undeclaredIdentities++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const identitiesByBucket = new Map();
|
|
123
|
+
let identityUnknown = 0;
|
|
124
|
+
// A visible execution binding the reach read never reported on was not evaluated.
|
|
125
|
+
if (projection.identityReach !== undefined) {
|
|
126
|
+
const reported = new Set(projection.identityReach.map(record => record.executionBindingId));
|
|
127
|
+
for (const node of projection.nodes)
|
|
128
|
+
if (node.label === 'GuardExecutionBinding' && !reported.has(node.entityId))
|
|
129
|
+
identityUnknown++;
|
|
130
|
+
}
|
|
131
|
+
// A target the read cannot match may be any resource, so it counts like no target.
|
|
132
|
+
const hostIds = new Set(projection.nodes
|
|
133
|
+
.filter(node => node.label === 'GuardExternalResource')
|
|
134
|
+
.map(node => string(node, 'host_resource_id'))
|
|
135
|
+
.filter((id) => id !== null));
|
|
136
|
+
let unresolvedPaths = 0;
|
|
137
|
+
for (const [target, paths] of directByTarget)
|
|
138
|
+
if (!hostIds.has(target))
|
|
139
|
+
unresolvedPaths += paths.length;
|
|
140
|
+
for (const record of projection.identityReach ?? []) {
|
|
141
|
+
work.step(1 + record.extraResources.length);
|
|
142
|
+
// Only a complete comparison counts: any other status, or a record whose list was cut
|
|
143
|
+
// (it may reach buckets it no longer names), leaves reach unknown.
|
|
144
|
+
if (!(record.status === 'matches_grants' || record.status === 'broader_than_grants') || record.truncated)
|
|
145
|
+
identityUnknown++;
|
|
146
|
+
identityAccounts.add(record.accountId ?? null);
|
|
147
|
+
for (const extra of record.extraResources) {
|
|
148
|
+
const reached = identitiesByBucket.get(extra.bucketName);
|
|
149
|
+
if (reached)
|
|
150
|
+
reached.push({ record, extra });
|
|
151
|
+
else
|
|
152
|
+
identitiesByBucket.set(extra.bucketName, [{ record, extra }]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const result = {
|
|
156
|
+
undeclaredDeployments,
|
|
157
|
+
untargetedPaths,
|
|
158
|
+
unresolvedPaths,
|
|
159
|
+
undeclaredIdentities,
|
|
160
|
+
directByTarget,
|
|
161
|
+
identitiesByBucket,
|
|
162
|
+
identityUnknown,
|
|
163
|
+
identityCollected: projection.identityReach !== undefined,
|
|
164
|
+
identityAccounts,
|
|
165
|
+
};
|
|
166
|
+
reachIndex.set(projection, result);
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
/** Entries listed per resource in get_resource_access before the list is marked truncated. */
|
|
170
|
+
export const REACH_LIST_LIMIT = 100;
|
|
171
|
+
/**
|
|
172
|
+
* `detail` builds the (bounded) lists for one resource; the listing only needs the status,
|
|
173
|
+
* which comes from index lookups.
|
|
174
|
+
*/
|
|
175
|
+
export function outsideGuardReach(projection, resource, scope, work, detail = false) {
|
|
176
|
+
const index = outsideGuardIndex(projection, work);
|
|
177
|
+
const targets = connected(projection, new Set([resource.id]), 'G_MAPS_TO', work);
|
|
178
|
+
// The collector writes one target per binding; anything else is malformed and never none_found.
|
|
179
|
+
const malformed = targets.length !== 1;
|
|
180
|
+
const hosts = targets.map(node => string(node, 'host_resource_id')).filter((id) => id !== null);
|
|
181
|
+
const accounts = new Set(targets.filter(node => string(node, 'provider') === 'aws').map(node => string(node, 'account_id')));
|
|
182
|
+
const buckets = targets
|
|
183
|
+
.filter(node => string(node, 'provider') === 'aws')
|
|
184
|
+
.map(node => s3BucketName(string(node, 'arn')))
|
|
185
|
+
.filter((name) => name !== null);
|
|
186
|
+
work.step(1 + hosts.length + buckets.length);
|
|
187
|
+
const reached = hosts.some(id => index.directByTarget.has(id)) || buckets.some(name => index.identitiesByBucket.has(name));
|
|
188
|
+
let directAccess = [], identities = [], directAccessTruncated = false, identitiesTruncated = false;
|
|
189
|
+
if (detail) {
|
|
190
|
+
const paths = hosts
|
|
191
|
+
.flatMap(id => index.directByTarget.get(id) ?? [])
|
|
192
|
+
.sort((a, b) => compare(a.entityId, b.entityId));
|
|
193
|
+
const reaching = buckets
|
|
194
|
+
.flatMap(name => index.identitiesByBucket.get(name) ?? [])
|
|
195
|
+
.sort((a, b) => compare(a.record.executionBindingId, b.record.executionBindingId) ||
|
|
196
|
+
compare(a.extra.bucketName, b.extra.bucketName) ||
|
|
197
|
+
compare(a.extra.relationship, b.extra.relationship));
|
|
198
|
+
work.step(paths.length + reaching.length);
|
|
199
|
+
directAccessTruncated = paths.length > REACH_LIST_LIMIT;
|
|
200
|
+
identitiesTruncated = reaching.length > REACH_LIST_LIMIT;
|
|
201
|
+
directAccess = paths.slice(0, REACH_LIST_LIMIT).map(node => ({
|
|
202
|
+
id: node.entityId,
|
|
203
|
+
accessId: string(node, 'access_id'),
|
|
204
|
+
deploymentId: string(node, 'deployment_id'),
|
|
205
|
+
kind: string(node, 'kind'),
|
|
206
|
+
description: string(node, 'description'),
|
|
207
|
+
evidenceKind: 'declared',
|
|
208
|
+
}));
|
|
209
|
+
identities = reaching.slice(0, REACH_LIST_LIMIT).map(({ record, extra }) => jsonObject({
|
|
210
|
+
id: record.executionBindingId,
|
|
211
|
+
executionBindingId: record.executionBindingId,
|
|
212
|
+
deploymentId: record.deploymentId,
|
|
213
|
+
tool: record.tool,
|
|
214
|
+
identityId: record.identityId,
|
|
215
|
+
relationship: extra.relationship,
|
|
216
|
+
hasCondition: extra.hasCondition,
|
|
217
|
+
conditionKeys: extra.conditionKeys,
|
|
218
|
+
evidenceKind: 'iam_identity_policy_candidate',
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
const unknownReasons = [];
|
|
222
|
+
if (malformed)
|
|
223
|
+
unknownReasons.push('binding_ambiguous');
|
|
224
|
+
// Candidates exist only for buckets the AWS collection observed; an unresolved bucket was
|
|
225
|
+
// never compared with any identity.
|
|
226
|
+
if (targets.some(node => string(node, 'provider') === 'aws' && string(node, 'cloud_status') !== 'resolved'))
|
|
227
|
+
unknownReasons.push('resource_not_collected');
|
|
228
|
+
if (index.undeclaredDeployments)
|
|
229
|
+
unknownReasons.push('direct_access_undeclared');
|
|
230
|
+
if (index.untargetedPaths)
|
|
231
|
+
unknownReasons.push('direct_access_untargeted');
|
|
232
|
+
if (index.unresolvedPaths)
|
|
233
|
+
unknownReasons.push('direct_access_target_unresolved');
|
|
234
|
+
if (!buckets.length)
|
|
235
|
+
unknownReasons.push('identity_scope_not_collected_for_resource');
|
|
236
|
+
else if (!index.identityCollected)
|
|
237
|
+
unknownReasons.push('identity_scope_not_collected');
|
|
238
|
+
else {
|
|
239
|
+
if (index.identityUnknown)
|
|
240
|
+
unknownReasons.push('identity_scope_unknown');
|
|
241
|
+
// Candidates are collected per account: an identity elsewhere was never compared with
|
|
242
|
+
// this bucket, and a cross-account bucket policy can still let it in.
|
|
243
|
+
if ([...index.identityAccounts].some(account => account === null || !accounts.has(account)) || accounts.has(null))
|
|
244
|
+
unknownReasons.push('identity_scope_other_account');
|
|
245
|
+
if (index.undeclaredIdentities)
|
|
246
|
+
unknownReasons.push('execution_identity_undeclared');
|
|
247
|
+
}
|
|
248
|
+
// Paths and identities of teams this reader cannot see may reach the resource too.
|
|
249
|
+
if (!scope.allTeams && projection.outsideVisibleScope !== false)
|
|
250
|
+
unknownReasons.push('outside_visible_scope');
|
|
251
|
+
const status = reached ? 'yes' : unknownReasons.length ? 'unknown' : 'none_found';
|
|
252
|
+
return { status, unknownReasons, directAccess, directAccessTruncated, identities, identitiesTruncated };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Reach with its own work budget, shared by every row of one response: if it runs out, that
|
|
256
|
+
* row and every later one read `unknown` (`reach_not_evaluated`) and the response still
|
|
257
|
+
* answers. Rows judged before that keep their status: each used a complete index.
|
|
258
|
+
*/
|
|
259
|
+
export function boundedReach(remaining, maxBytes) {
|
|
260
|
+
const work = new QueryWork(Math.max(1, remaining - 25), maxBytes);
|
|
261
|
+
let exhausted = false;
|
|
262
|
+
return (projection, resource, scope, detail = false) => {
|
|
263
|
+
if (!exhausted)
|
|
264
|
+
try {
|
|
265
|
+
return outsideGuardReach(projection, resource, scope, work, detail);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
const code = error instanceof MapQueryError ? error.code : '';
|
|
269
|
+
if (code !== 'MAP_LIMIT_EXCEEDED' && code !== 'MAP_TIMEOUT')
|
|
270
|
+
throw error;
|
|
271
|
+
exhausted = true;
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
status: 'unknown',
|
|
275
|
+
unknownReasons: ['reach_not_evaluated'],
|
|
276
|
+
directAccess: [],
|
|
277
|
+
directAccessTruncated: false,
|
|
278
|
+
identities: [],
|
|
279
|
+
identitiesTruncated: false,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export const OUTSIDE_GUARD_LIMITATION = 'Only S3 identity policies and host declarations of this source are checked, within your visible scope; none_found is not proof that no path exists.';
|
|
284
|
+
/**
|
|
285
|
+
* C2 per execution binding in the agent's focus. A store that did not run the reverse identity
|
|
286
|
+
* read reports every binding as `unknown` rather than leaving it out.
|
|
287
|
+
*/
|
|
288
|
+
export function identityScope(projection, graph) {
|
|
289
|
+
const executions = graph.nodes.filter(node => node.label === 'GuardExecutionBinding');
|
|
290
|
+
const known = new Map((projection.identityScope ?? []).map(item => [item.executionBindingId, item]));
|
|
291
|
+
return ordered(executions.map(execution => {
|
|
292
|
+
const item = known.get(execution.entityId);
|
|
293
|
+
return jsonObject({
|
|
294
|
+
id: execution.entityId,
|
|
295
|
+
...(item ?? {
|
|
296
|
+
executionBindingId: execution.entityId,
|
|
297
|
+
deploymentId: null,
|
|
298
|
+
tool: string(execution, 'tool'),
|
|
299
|
+
identityId: null,
|
|
300
|
+
accountId: null,
|
|
301
|
+
status: 'unknown',
|
|
302
|
+
// The store did not evaluate this binding (not run, or more work than allowed).
|
|
303
|
+
reason: 'not_evaluated',
|
|
304
|
+
observedAt: null,
|
|
305
|
+
boundBuckets: [],
|
|
306
|
+
extraResources: [],
|
|
307
|
+
truncated: false,
|
|
308
|
+
evidenceKind: 'iam_identity_policy_candidate',
|
|
309
|
+
assessment: 'unknown',
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type JsonObject, type MapNode, type MapPolicyEvidence, type MapProjection } from '../types.js';
|
|
2
|
+
import { QueryWork } from './work.js';
|
|
3
|
+
export declare const POLICY_ACTIONS: Readonly<Record<string, string>>;
|
|
4
|
+
export declare const POLICY_REASONS: Readonly<{
|
|
5
|
+
unavailable: string[];
|
|
6
|
+
not_collected: string[];
|
|
7
|
+
}>;
|
|
8
|
+
export declare const POLICY_FIELDS: string;
|
|
9
|
+
export declare function policyObject(value: unknown): value is Record<string, unknown>;
|
|
10
|
+
export declare function policyTime(value: unknown): boolean;
|
|
11
|
+
export declare function policyCoverage(records: readonly MapPolicyEvidence[]): JsonObject;
|
|
12
|
+
/**
|
|
13
|
+
* Coverage for a selection of one projection's policy evidence. When the store found more
|
|
14
|
+
* task/binding/action pairs than one read evaluates it loaded none: that is unknown, never
|
|
15
|
+
* "not collected".
|
|
16
|
+
*/
|
|
17
|
+
export declare function policyCoverageOf(projection: MapProjection, records: readonly MapPolicyEvidence[]): JsonObject;
|
|
18
|
+
export declare function selectedPolicy(projection: MapProjection, work: QueryWork, options?: {
|
|
19
|
+
nodes?: readonly MapNode[];
|
|
20
|
+
task?: MapNode;
|
|
21
|
+
executions?: readonly MapNode[];
|
|
22
|
+
resources?: readonly MapNode[];
|
|
23
|
+
action?: string;
|
|
24
|
+
}): readonly MapPolicyEvidence[];
|
|
25
|
+
/** The projection's policy evidence, validated once per query. */
|
|
26
|
+
export declare function policyRecords(projection: MapProjection, work: QueryWork): readonly MapPolicyEvidence[];
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { immutableJson } from '@shieldaai/agent-guard/internal';
|
|
3
|
+
import { compareCodePoints, fail } from '../common.js';
|
|
4
|
+
import { mapIdentifier, } from '../types.js';
|
|
5
|
+
import { connected, QueryWork, string, strings } from './work.js';
|
|
6
|
+
export const POLICY_ACTIONS = Object.freeze({
|
|
7
|
+
's3:GetObject': 'CAN_READ',
|
|
8
|
+
's3:PutObject': 'CAN_WRITE',
|
|
9
|
+
's3:ListBucket': 'CAN_LIST',
|
|
10
|
+
});
|
|
11
|
+
export const POLICY_REASONS = Object.freeze({
|
|
12
|
+
unavailable: [
|
|
13
|
+
'binding_ambiguous',
|
|
14
|
+
'cloud_unresolved',
|
|
15
|
+
'provider_missing',
|
|
16
|
+
'collection_invalid',
|
|
17
|
+
'stale',
|
|
18
|
+
'changed',
|
|
19
|
+
'boundary_unresolved',
|
|
20
|
+
'unsupported_policy',
|
|
21
|
+
'evidence_invalid',
|
|
22
|
+
],
|
|
23
|
+
not_collected: [
|
|
24
|
+
'unsupported_action',
|
|
25
|
+
'identity_not_static',
|
|
26
|
+
'unsupported_binding',
|
|
27
|
+
'cloud_not_collected',
|
|
28
|
+
'collection_missing',
|
|
29
|
+
'disabled',
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
export const POLICY_FIELDS = [
|
|
33
|
+
'taskId',
|
|
34
|
+
'deploymentId',
|
|
35
|
+
'executionBindingId',
|
|
36
|
+
'resourceBindingId',
|
|
37
|
+
'identityId',
|
|
38
|
+
'resourceId',
|
|
39
|
+
'action',
|
|
40
|
+
'status',
|
|
41
|
+
'reason',
|
|
42
|
+
'observedAt',
|
|
43
|
+
'assessment',
|
|
44
|
+
'candidates',
|
|
45
|
+
]
|
|
46
|
+
.sort()
|
|
47
|
+
.join(',');
|
|
48
|
+
export function policyObject(value) {
|
|
49
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
export function policyTime(value) {
|
|
52
|
+
if (typeof value !== 'string')
|
|
53
|
+
return false;
|
|
54
|
+
const instant = Date.parse(value);
|
|
55
|
+
return (Number.isFinite(instant) &&
|
|
56
|
+
instant >= 0 &&
|
|
57
|
+
instant <= 253_402_300_799_999 &&
|
|
58
|
+
new Date(instant).toISOString() === value);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A copy made by `immutableJson` is plain JSON; the checks in `validatedPolicyEvidence` are what
|
|
62
|
+
* establish that each entry has the record's shape, so nothing reads a field before they run.
|
|
63
|
+
*/
|
|
64
|
+
const policyList = (copy) => copy;
|
|
65
|
+
/** Custom stores receive the same closed output contract and binding-path checks. */
|
|
66
|
+
function validatedPolicyEvidence(projection, work) {
|
|
67
|
+
const rawInput = projection.policyEvidence;
|
|
68
|
+
if (rawInput === undefined)
|
|
69
|
+
return [];
|
|
70
|
+
if (!Array.isArray(rawInput))
|
|
71
|
+
fail('MAP_UNAVAILABLE');
|
|
72
|
+
if (rawInput.length > 512)
|
|
73
|
+
fail('MAP_LIMIT_EXCEEDED');
|
|
74
|
+
let input;
|
|
75
|
+
try {
|
|
76
|
+
input = policyList(immutableJson(rawInput, 8, 100_000, 4 * 1024 * 1024));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return fail('MAP_UNAVAILABLE');
|
|
80
|
+
}
|
|
81
|
+
work.step(projection.nodes.length);
|
|
82
|
+
const entities = new Map(projection.nodes.map(node => [node.entityId, node]));
|
|
83
|
+
if (input.length && entities.size !== projection.nodes.length)
|
|
84
|
+
fail('MAP_UNAVAILABLE');
|
|
85
|
+
const seen = new Set(), result = [];
|
|
86
|
+
let materializedBytes = 0;
|
|
87
|
+
const link = (source, target, kind) => {
|
|
88
|
+
const targets = work.index(projection).outgoing.get(source.id)?.get(kind) ?? [];
|
|
89
|
+
work.step(targets.length);
|
|
90
|
+
return targets.includes(target.id);
|
|
91
|
+
};
|
|
92
|
+
const exactLink = (source, target, kind) => {
|
|
93
|
+
const targets = work.index(projection).outgoing.get(source.id)?.get(kind) ?? [];
|
|
94
|
+
work.step(targets.length);
|
|
95
|
+
return targets.length === 1 && targets[0] === target.id;
|
|
96
|
+
};
|
|
97
|
+
// Tasks may have several grants and grants several selectors, but the
|
|
98
|
+
// particular pair proving this record must not have duplicate relationships.
|
|
99
|
+
const uniqueLink = (source, target, kind) => {
|
|
100
|
+
const targets = work.index(projection).outgoing.get(source.id)?.get(kind) ?? [];
|
|
101
|
+
work.step(targets.length);
|
|
102
|
+
return targets.filter(id => id === target.id).length === 1;
|
|
103
|
+
};
|
|
104
|
+
const endpoint = (id, label) => {
|
|
105
|
+
mapIdentifier(id, 'MAP_UNAVAILABLE');
|
|
106
|
+
const node = entities.get(id);
|
|
107
|
+
if (!node || node.label !== label)
|
|
108
|
+
fail('MAP_UNAVAILABLE');
|
|
109
|
+
return node;
|
|
110
|
+
};
|
|
111
|
+
for (const raw of input) {
|
|
112
|
+
work.step();
|
|
113
|
+
if (!policyObject(raw) ||
|
|
114
|
+
Object.keys(raw).sort().join(',') !== POLICY_FIELDS ||
|
|
115
|
+
raw.assessment !== 'unknown' ||
|
|
116
|
+
typeof raw.action !== 'string' ||
|
|
117
|
+
(raw.observedAt !== null && !policyTime(raw.observedAt)))
|
|
118
|
+
fail('MAP_UNAVAILABLE');
|
|
119
|
+
mapIdentifier(raw.action, 'MAP_UNAVAILABLE');
|
|
120
|
+
// Generic host actions carry no cloud-policy inference. Keep their explicit
|
|
121
|
+
// unsupported observation readable without admitting a cloud candidate.
|
|
122
|
+
const genericAction = !/^[a-z0-9][a-z0-9-]*:[A-Za-z][A-Za-z0-9]*$/.test(raw.action);
|
|
123
|
+
if (genericAction) {
|
|
124
|
+
if (raw.status !== 'not_collected' || raw.reason !== 'unsupported_action')
|
|
125
|
+
fail('MAP_UNAVAILABLE');
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(raw.candidates) || raw.candidates.length > 1)
|
|
128
|
+
fail('MAP_UNAVAILABLE');
|
|
129
|
+
if (raw.status === 'complete') {
|
|
130
|
+
if (!policyTime(raw.observedAt) ||
|
|
131
|
+
!POLICY_ACTIONS[raw.action] ||
|
|
132
|
+
raw.reason !== (raw.candidates.length ? null : 'no_candidate'))
|
|
133
|
+
fail('MAP_UNAVAILABLE');
|
|
134
|
+
}
|
|
135
|
+
else if ((raw.status !== 'unavailable' && raw.status !== 'not_collected') ||
|
|
136
|
+
!POLICY_REASONS[raw.status].includes(raw.reason) ||
|
|
137
|
+
raw.candidates.length)
|
|
138
|
+
fail('MAP_UNAVAILABLE');
|
|
139
|
+
for (const candidate of raw.candidates) {
|
|
140
|
+
if (!policyObject(candidate) ||
|
|
141
|
+
Object.keys(candidate).sort().join(',') !== 'conditionKeys,hasCondition,relationship,resourceScope' ||
|
|
142
|
+
candidate.relationship !== POLICY_ACTIONS[raw.action] ||
|
|
143
|
+
candidate.resourceScope !== 'bucket_summary' ||
|
|
144
|
+
typeof candidate.hasCondition !== 'boolean' ||
|
|
145
|
+
!Array.isArray(candidate.conditionKeys) ||
|
|
146
|
+
candidate.conditionKeys.length > 128)
|
|
147
|
+
fail('MAP_UNAVAILABLE');
|
|
148
|
+
for (const key of candidate.conditionKeys) {
|
|
149
|
+
work.step();
|
|
150
|
+
mapIdentifier(key, 'MAP_UNAVAILABLE');
|
|
151
|
+
if (Buffer.byteLength(key, 'utf8') > 256 || /[\u0000-\u001f]/.test(key))
|
|
152
|
+
fail('MAP_UNAVAILABLE');
|
|
153
|
+
}
|
|
154
|
+
if (candidate.hasCondition !== !!candidate.conditionKeys.length ||
|
|
155
|
+
JSON.stringify(candidate.conditionKeys) !==
|
|
156
|
+
JSON.stringify([...new Set(candidate.conditionKeys)].sort(compareCodePoints)))
|
|
157
|
+
fail('MAP_UNAVAILABLE');
|
|
158
|
+
}
|
|
159
|
+
const task = endpoint(raw.taskId, 'GuardTask'), deployment = endpoint(raw.deploymentId, 'GuardDeployment');
|
|
160
|
+
const execution = endpoint(raw.executionBindingId, 'GuardExecutionBinding'), resource = endpoint(raw.resourceBindingId, 'GuardResourceBinding');
|
|
161
|
+
const tool = string(execution, 'tool');
|
|
162
|
+
if (!tool ||
|
|
163
|
+
string(resource, 'tool') !== tool ||
|
|
164
|
+
!strings(resource, 'actions').includes(raw.action) ||
|
|
165
|
+
!link(task, deployment, 'G_ASSIGNED_TO') ||
|
|
166
|
+
!link(execution, deployment, 'G_EXECUTES_FOR'))
|
|
167
|
+
fail('MAP_UNAVAILABLE');
|
|
168
|
+
const tools = connected(projection, new Set([execution.id]), 'G_EXECUTES_TOOL', work).filter(node => node.label === 'GuardTool' && string(node, 'name') === tool);
|
|
169
|
+
const grants = connected(projection, new Set([task.id]), 'G_HAS_GRANT', work).filter(node => node.label === 'GuardGrant' && string(node, 'tool') === tool);
|
|
170
|
+
const selectors = connected(projection, new Set([resource.id]), 'G_BINDS_SELECTOR', work).filter(node => node.label === 'GuardResource' &&
|
|
171
|
+
string(node, 'tool') === tool &&
|
|
172
|
+
string(node, 'resource_id') === string(resource, 'resource_ref'));
|
|
173
|
+
if (!grants.some(grant => strings(grant, 'resources').includes(string(resource, 'resource_ref') ?? '') &&
|
|
174
|
+
(raw.status !== 'complete' || uniqueLink(task, grant, 'G_HAS_GRANT')) &&
|
|
175
|
+
tools.some(toolNode => (raw.status === 'complete' ? exactLink : link)(grant, toolNode, 'G_FOR_TOOL')) &&
|
|
176
|
+
selectors.some(selector => (raw.status === 'complete' ? uniqueLink : link)(grant, selector, 'G_TARGETS'))))
|
|
177
|
+
fail('MAP_UNAVAILABLE');
|
|
178
|
+
for (const [key, label, relation, owner] of [
|
|
179
|
+
['identityId', 'GuardExternalIdentity', 'G_USES_IDENTITY', execution],
|
|
180
|
+
['resourceId', 'GuardExternalResource', 'G_MAPS_TO', resource],
|
|
181
|
+
]) {
|
|
182
|
+
if (raw[key] === null) {
|
|
183
|
+
if (raw.status === 'complete')
|
|
184
|
+
fail('MAP_UNAVAILABLE');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const target = endpoint(raw[key], label);
|
|
188
|
+
if (!(raw.status === 'complete' ? exactLink(owner, target, relation) : link(owner, target, relation)))
|
|
189
|
+
fail('MAP_UNAVAILABLE');
|
|
190
|
+
}
|
|
191
|
+
if (genericAction &&
|
|
192
|
+
(raw.resourceId === null || string(endpoint(raw.resourceId, 'GuardExternalResource'), 'provider') !== 'host'))
|
|
193
|
+
fail('MAP_UNAVAILABLE');
|
|
194
|
+
if (raw.status === 'complete') {
|
|
195
|
+
const agents = connected(projection, new Set([deployment.id]), 'G_INSTANCE_OF', work).filter(node => node.label === 'GuardAgent');
|
|
196
|
+
if (agents.length !== 1 ||
|
|
197
|
+
!exactLink(deployment, agents[0], 'G_INSTANCE_OF') ||
|
|
198
|
+
string(execution, 'identity_selection') !== 'static' ||
|
|
199
|
+
!exactLink(task, deployment, 'G_ASSIGNED_TO') ||
|
|
200
|
+
!exactLink(execution, deployment, 'G_EXECUTES_FOR') ||
|
|
201
|
+
tools.length !== 1 ||
|
|
202
|
+
!exactLink(execution, tools[0], 'G_EXECUTES_TOOL') ||
|
|
203
|
+
selectors.length !== 1 ||
|
|
204
|
+
!exactLink(resource, selectors[0], 'G_BINDS_SELECTOR'))
|
|
205
|
+
fail('MAP_UNAVAILABLE');
|
|
206
|
+
}
|
|
207
|
+
const identity = JSON.stringify([
|
|
208
|
+
raw.taskId,
|
|
209
|
+
raw.deploymentId,
|
|
210
|
+
raw.executionBindingId,
|
|
211
|
+
raw.resourceBindingId,
|
|
212
|
+
raw.action,
|
|
213
|
+
]);
|
|
214
|
+
if (seen.has(identity))
|
|
215
|
+
fail('MAP_UNAVAILABLE');
|
|
216
|
+
seen.add(identity);
|
|
217
|
+
try {
|
|
218
|
+
const copy = policyList([immutableJson(raw, 6, 1024, 64 * 1024)])[0];
|
|
219
|
+
materializedBytes += Buffer.byteLength(JSON.stringify(copy));
|
|
220
|
+
if (materializedBytes > 4 * 1024 * 1024)
|
|
221
|
+
fail('MAP_LIMIT_EXCEEDED');
|
|
222
|
+
result.push(copy);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
fail('MAP_LIMIT_EXCEEDED');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
export function policyCoverage(records) {
|
|
231
|
+
const complete = records.filter(item => item.status === 'complete').length;
|
|
232
|
+
const observed = records.flatMap(item => (item.observedAt === null ? [] : [item.observedAt])).sort();
|
|
233
|
+
return {
|
|
234
|
+
status: !records.length || records.every(item => item.status === 'not_collected')
|
|
235
|
+
? 'not_collected'
|
|
236
|
+
: complete === records.length
|
|
237
|
+
? 'complete'
|
|
238
|
+
: complete
|
|
239
|
+
? 'partial'
|
|
240
|
+
: 'unavailable',
|
|
241
|
+
observedAt: observed[0] ?? null,
|
|
242
|
+
assessment: 'unknown',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Coverage for a selection of one projection's policy evidence. When the store found more
|
|
247
|
+
* task/binding/action pairs than one read evaluates it loaded none: that is unknown, never
|
|
248
|
+
* "not collected".
|
|
249
|
+
*/
|
|
250
|
+
export function policyCoverageOf(projection, records) {
|
|
251
|
+
return projection.policyEvidenceNotLoaded === true
|
|
252
|
+
? { status: 'unknown', reason: 'limit_exceeded', observedAt: null, assessment: 'unknown' }
|
|
253
|
+
: policyCoverage(records);
|
|
254
|
+
}
|
|
255
|
+
export function selectedPolicy(projection, work, options = {}) {
|
|
256
|
+
const ids = options.nodes ? new Set(options.nodes.map(node => node.entityId)) : undefined;
|
|
257
|
+
const executions = options.executions ? new Set(options.executions.map(node => node.entityId)) : undefined;
|
|
258
|
+
const resources = options.resources ? new Set(options.resources.map(node => node.entityId)) : undefined;
|
|
259
|
+
const records = options.task
|
|
260
|
+
? policyRecordsOfTask(projection, options.task.entityId, work)
|
|
261
|
+
: policyRecords(projection, work);
|
|
262
|
+
return records.filter(record => {
|
|
263
|
+
work.step();
|
|
264
|
+
return ((!ids ||
|
|
265
|
+
[
|
|
266
|
+
record.taskId,
|
|
267
|
+
record.deploymentId,
|
|
268
|
+
record.executionBindingId,
|
|
269
|
+
record.resourceBindingId,
|
|
270
|
+
record.identityId,
|
|
271
|
+
record.resourceId,
|
|
272
|
+
].every(id => id === null || ids.has(id))) &&
|
|
273
|
+
(!options.task || record.taskId === options.task.entityId) &&
|
|
274
|
+
(!executions || executions.has(record.executionBindingId)) &&
|
|
275
|
+
(!resources || resources.has(record.resourceBindingId)) &&
|
|
276
|
+
(!options.action || record.action === options.action));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/** The projection's policy evidence, validated once per query. */
|
|
280
|
+
export function policyRecords(projection, work) {
|
|
281
|
+
return work.cached(projection, 'policy', () => validatedPolicyEvidence(projection, work));
|
|
282
|
+
}
|
|
283
|
+
/** Validated policy records of one task, in their original order; indexed once per projection. */
|
|
284
|
+
function policyRecordsOfTask(projection, taskEntityId, work) {
|
|
285
|
+
const index = work.cached(projection, 'policyByTask', () => {
|
|
286
|
+
const records = policyRecords(projection, work);
|
|
287
|
+
work.step(records.length);
|
|
288
|
+
const byTask = new Map();
|
|
289
|
+
for (const record of records) {
|
|
290
|
+
const items = byTask.get(record.taskId);
|
|
291
|
+
if (items)
|
|
292
|
+
items.push(record);
|
|
293
|
+
else
|
|
294
|
+
byTask.set(record.taskId, [record]);
|
|
295
|
+
}
|
|
296
|
+
return byTask;
|
|
297
|
+
});
|
|
298
|
+
return index.get(taskEntityId) ?? [];
|
|
299
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type JsonObject, type JsonValue } from '../types.js';
|
|
2
|
+
export declare const MAP_OPERATIONS: readonly ['list_agents', 'get_agent_access', 'explain_access', 'find_agents_with_access', 'get_changes', 'get_inventory_health', 'list_owners', 'list_resources', 'get_resource_access', 'get_agent_inventory', 'list_undeclared_activity', 'list_unclaimed_identities'];
|
|
3
|
+
export type MapOperation = (typeof MAP_OPERATIONS)[number];
|
|
4
|
+
export type QueryArguments = Readonly<Record<string, JsonValue>>;
|
|
5
|
+
/** What each operation answers, in one sentence. Shown to MCP clients as the tool description. */
|
|
6
|
+
export declare const MAP_OPERATION_DESCRIPTIONS: Readonly<Record<MapOperation, string>>;
|
|
7
|
+
export declare const sections: readonly ['overview', 'owners', 'deployments', 'tasks', 'permissions', 'resources', 'connections', 'credentials', 'relationships', 'tools', 'bindings', 'identities', 'policies'];
|
|
8
|
+
export type InventorySection = (typeof sections)[number];
|
|
9
|
+
export declare const MAP_INPUT_SCHEMAS: Readonly<Record<MapOperation, JsonObject>>;
|
|
10
|
+
/** The arguments of one operation, copied and checked against its closed schema. */
|
|
11
|
+
export declare function validateQueryArguments(operation: unknown, value: unknown): QueryArguments;
|
|
12
|
+
/** The page an operation was asked for; 50 rows from the start unless the caller says otherwise. */
|
|
13
|
+
export declare function pageArguments(args: QueryArguments): {
|
|
14
|
+
offset: number;
|
|
15
|
+
limit: number;
|
|
16
|
+
};
|