@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
package/dist/service.js
CHANGED
|
@@ -1,499 +1,40 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import { immutableJson } from '@shieldaai/agent-guard/internal';
|
|
2
|
+
import { fail, integer } from './common.js';
|
|
3
|
+
import { MapQueryError, validateReadScope, } from './types.js';
|
|
4
|
+
import { validateQueryArguments } from './service/schemas.js';
|
|
5
|
+
import { QueryWork } from './service/work.js';
|
|
6
|
+
import { shortenIdentityScope } from './service/outside-guard.js';
|
|
7
|
+
import { listAgents } from './service/operations/list-agents.js';
|
|
8
|
+
import { getAgentAccess } from './service/operations/get-agent-access.js';
|
|
9
|
+
import { explainAccess } from './service/operations/explain-access.js';
|
|
10
|
+
import { findAgentsWithAccess } from './service/operations/find-agents-with-access.js';
|
|
11
|
+
import { getChanges } from './service/operations/get-changes.js';
|
|
12
|
+
import { getInventoryHealth } from './service/operations/get-inventory-health.js';
|
|
13
|
+
import { listOwners, listResources } from './service/operations/list-owners-resources.js';
|
|
14
|
+
import { getResourceAccess } from './service/operations/get-resource-access.js';
|
|
15
|
+
import { getAgentInventory } from './service/operations/get-agent-inventory.js';
|
|
16
|
+
import { listUnclaimedIdentities, listUndeclaredActivity } from './service/operations/discovery.js';
|
|
17
|
+
export { MAP_OPERATIONS, MAP_INPUT_SCHEMAS, MAP_OPERATION_DESCRIPTIONS, validateQueryArguments, } from './service/schemas.js';
|
|
18
|
+
// The closed vocabularies of the graph, for a viewer that labels every value it can be sent.
|
|
19
|
+
export { MAP_LABELS, MAP_RELATIONSHIPS } from './types.js';
|
|
20
|
+
export { MAP_PROPERTIES } from './projection-fields.js';
|
|
21
|
+
/** One handler per operation; each lives in its own module under `service/operations`. */
|
|
22
|
+
const OPERATIONS = Object.freeze({
|
|
23
|
+
list_agents: listAgents,
|
|
24
|
+
get_agent_access: getAgentAccess,
|
|
25
|
+
explain_access: explainAccess,
|
|
26
|
+
find_agents_with_access: findAgentsWithAccess,
|
|
27
|
+
get_changes: getChanges,
|
|
28
|
+
get_inventory_health: getInventoryHealth,
|
|
29
|
+
list_owners: listOwners,
|
|
30
|
+
list_resources: listResources,
|
|
31
|
+
get_resource_access: getResourceAccess,
|
|
32
|
+
get_agent_inventory: getAgentInventory,
|
|
33
|
+
list_undeclared_activity: listUndeclaredActivity,
|
|
34
|
+
list_unclaimed_identities: listUnclaimedIdentities,
|
|
18
35
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const chosen = value === undefined ? fallback : value;
|
|
22
|
-
if (typeof chosen !== 'number' || !Number.isSafeInteger(chosen) || chosen < minimum || chosen > maximum)
|
|
23
|
-
fail('MAP_CONFIG_INVALID');
|
|
24
|
-
return chosen;
|
|
25
|
-
}
|
|
26
|
-
export function validateQueryArguments(operation, value) {
|
|
27
|
-
if (typeof operation !== 'string' || !MAP_OPERATIONS.includes(operation))
|
|
28
|
-
fail();
|
|
29
|
-
let copied;
|
|
30
|
-
try {
|
|
31
|
-
copied = immutableJson(value, 4, 512, 64 * 1024);
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return fail();
|
|
35
|
-
}
|
|
36
|
-
if (!copied || typeof copied !== 'object' || Array.isArray(copied))
|
|
37
|
-
fail();
|
|
38
|
-
const args = copied;
|
|
39
|
-
const definition = MAP_INPUT_SCHEMAS[operation];
|
|
40
|
-
const properties = definition.properties;
|
|
41
|
-
const required = definition.required;
|
|
42
|
-
if (Object.keys(args).some(key => !Object.hasOwn(properties, key)) || required.some(key => !Object.hasOwn(args, key)))
|
|
43
|
-
fail();
|
|
44
|
-
for (const [key, item] of Object.entries(args)) {
|
|
45
|
-
if (key === 'limit') {
|
|
46
|
-
if (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 1 || item > 100)
|
|
47
|
-
fail();
|
|
48
|
-
}
|
|
49
|
-
else if (key === 'search') {
|
|
50
|
-
if (typeof item !== 'string' || [...item].length > 256 || item.includes('\0') ||
|
|
51
|
-
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(item))
|
|
52
|
-
fail();
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
mapIdentifier(item);
|
|
56
|
-
if (key === 'action' && (typeof item !== 'string' || item.length > 256 || !/^[a-z0-9][a-z0-9-]*:[A-Za-z][A-Za-z0-9]*$/.test(item)))
|
|
57
|
-
fail();
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return args;
|
|
61
|
-
}
|
|
62
|
-
const string = (node, key) => typeof node.properties[key] === 'string' ? node.properties[key] : null;
|
|
63
|
-
const number = (node, key) => typeof node.properties[key] === 'number' ? node.properties[key] : null;
|
|
64
|
-
const strings = (node, key) => Array.isArray(node.properties[key]) ? node.properties[key].filter((v) => typeof v === 'string') : [];
|
|
65
|
-
const matching = (node, id, native) => node.entityId === id || string(node, native) === id;
|
|
66
|
-
const unique = (values) => [...new Set(values)];
|
|
67
|
-
/** Count authorized work and elapsed monotonic time, including synchronous projection. */
|
|
68
|
-
class QueryWork {
|
|
69
|
-
#deadline;
|
|
70
|
-
#indices = new WeakMap();
|
|
71
|
-
#policy = new WeakMap();
|
|
72
|
-
#remaining = 200_000;
|
|
73
|
-
#evidenceBytes;
|
|
74
|
-
#evidenceCount = 0;
|
|
75
|
-
constructor(timeout, maxBytes) { this.#deadline = performance.now() + timeout; this.#evidenceBytes = maxBytes; }
|
|
76
|
-
step(amount = 1) {
|
|
77
|
-
if (performance.now() >= this.#deadline)
|
|
78
|
-
fail('MAP_TIMEOUT');
|
|
79
|
-
this.#remaining -= amount;
|
|
80
|
-
if (this.#remaining < 0)
|
|
81
|
-
fail('MAP_LIMIT_EXCEEDED');
|
|
82
|
-
}
|
|
83
|
-
index(projection) {
|
|
84
|
-
this.step();
|
|
85
|
-
const existing = this.#indices.get(projection);
|
|
86
|
-
if (existing)
|
|
87
|
-
return existing;
|
|
88
|
-
this.step(projection.nodes.length + projection.edges.length);
|
|
89
|
-
const result = { nodes: new Map(), outgoing: new Map(), incoming: new Map() };
|
|
90
|
-
for (const node of projection.nodes)
|
|
91
|
-
result.nodes.set(node.id, node);
|
|
92
|
-
const add = (map, from, kind, to) => {
|
|
93
|
-
let kinds = map.get(from);
|
|
94
|
-
if (!kinds) {
|
|
95
|
-
kinds = new Map();
|
|
96
|
-
map.set(from, kinds);
|
|
97
|
-
}
|
|
98
|
-
const items = kinds.get(kind);
|
|
99
|
-
if (items)
|
|
100
|
-
items.push(to);
|
|
101
|
-
else
|
|
102
|
-
kinds.set(kind, [to]);
|
|
103
|
-
};
|
|
104
|
-
for (const edge of projection.edges) {
|
|
105
|
-
add(result.outgoing, edge.source, edge.kind, edge.target);
|
|
106
|
-
add(result.incoming, edge.target, edge.kind, edge.source);
|
|
107
|
-
}
|
|
108
|
-
this.#indices.set(projection, result);
|
|
109
|
-
this.step();
|
|
110
|
-
return result;
|
|
111
|
-
}
|
|
112
|
-
evidence(value) {
|
|
113
|
-
this.step();
|
|
114
|
-
if (++this.#evidenceCount > 1000)
|
|
115
|
-
fail('MAP_LIMIT_EXCEEDED');
|
|
116
|
-
let copy;
|
|
117
|
-
try {
|
|
118
|
-
copy = immutableJson(value, 24, 25_000, this.#evidenceBytes);
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return fail('MAP_LIMIT_EXCEEDED');
|
|
122
|
-
}
|
|
123
|
-
this.#evidenceBytes -= Buffer.byteLength(JSON.stringify(copy));
|
|
124
|
-
this.step();
|
|
125
|
-
return copy;
|
|
126
|
-
}
|
|
127
|
-
policy(projection) {
|
|
128
|
-
const existing = this.#policy.get(projection);
|
|
129
|
-
if (existing)
|
|
130
|
-
return existing;
|
|
131
|
-
const result = validatedPolicyEvidence(projection, this);
|
|
132
|
-
this.#policy.set(projection, result);
|
|
133
|
-
return result;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
function connected(projection, ids, kind, work, reverse = false) {
|
|
137
|
-
const index = work.index(projection), selected = new Set();
|
|
138
|
-
work.step(ids.size);
|
|
139
|
-
for (const id of ids) {
|
|
140
|
-
const targets = (reverse ? index.incoming : index.outgoing).get(id)?.get(kind) ?? [];
|
|
141
|
-
work.step(targets.length);
|
|
142
|
-
for (const target of targets)
|
|
143
|
-
selected.add(target);
|
|
144
|
-
}
|
|
145
|
-
return [...selected].flatMap(id => index.nodes.get(id) ? [index.nodes.get(id)] : []);
|
|
146
|
-
}
|
|
147
|
-
const nodeIds = (nodes) => new Set(nodes.map(node => node.id));
|
|
148
|
-
function deployments(projection, agent, work) {
|
|
149
|
-
return connected(projection, new Set([agent.id]), 'G_INSTANCE_OF', work, true).filter(node => node.label === 'GuardDeployment');
|
|
150
|
-
}
|
|
151
|
-
function agentSummary(source, projection, agent, work) {
|
|
152
|
-
const items = deployments(projection, agent, work).map(node => ({ id: node.entityId,
|
|
153
|
-
deploymentId: string(node, 'deployment_id'), environment: string(node, 'environment') }));
|
|
154
|
-
return { id: agent.entityId, agentId: string(agent, 'agent_id'), sourceId: source.id,
|
|
155
|
-
name: string(agent, 'name'), ownerId: string(agent, 'owner_id'), teamId: string(agent, 'team_id'),
|
|
156
|
-
environments: unique(items.map(item => item.environment).filter((item) => item !== null)).sort(),
|
|
157
|
-
deployments: items };
|
|
158
|
-
}
|
|
159
|
-
function taskSummary(node) {
|
|
160
|
-
return { id: node.entityId, taskId: string(node, 'task_id'), principalId: string(node, 'principal_id'),
|
|
161
|
-
revoked: node.properties.revoked ?? null, sensitive: node.properties.sensitive ?? null,
|
|
162
|
-
usage: { calls: number(node, 'calls'), concurrent: number(node, 'concurrent'), spent: number(node, 'spent'), reserved: number(node, 'reserved') },
|
|
163
|
-
budget: { maxCalls: number(node, 'max_calls'), maxConcurrent: number(node, 'max_concurrent'), maxCost: number(node, 'max_cost'), deadline: number(node, 'deadline') } };
|
|
164
|
-
}
|
|
165
|
-
function grantSummary(node) {
|
|
166
|
-
return { id: node.entityId, taskId: string(node, 'task_id'), tool: string(node, 'tool'),
|
|
167
|
-
resources: strings(node, 'resources'), evidenceKind: 'declared', assessment: 'unknown' };
|
|
168
|
-
}
|
|
169
|
-
function receipt(source, projection = source.current) {
|
|
170
|
-
return { sourceId: source.id, tenantId: source.tenantId, profile: source.profile,
|
|
171
|
-
projectionId: projection.id, snapshotId: projection.snapshotId, generatedAt: projection.generatedAt,
|
|
172
|
-
coverage: projection.coverage };
|
|
173
|
-
}
|
|
174
|
-
function findSource(view, id) {
|
|
175
|
-
const source = view.sources.find(item => item.id === id);
|
|
176
|
-
if (!source)
|
|
177
|
-
fail('MAP_NOT_FOUND');
|
|
178
|
-
return source;
|
|
179
|
-
}
|
|
180
|
-
function findAgent(projection, id) {
|
|
181
|
-
const agent = projection.nodes.find(node => node.label === 'GuardAgent' && matching(node, id, 'agent_id'));
|
|
182
|
-
if (!agent)
|
|
183
|
-
fail('MAP_NOT_FOUND');
|
|
184
|
-
return agent;
|
|
185
|
-
}
|
|
186
|
-
/** Keep the selected agent's neighborhood explicit; never expand through shared tools to other agents. */
|
|
187
|
-
function focus(projection, agent, work) {
|
|
188
|
-
const deployment = deployments(projection, agent, work);
|
|
189
|
-
const tasks = connected(projection, nodeIds(deployment), 'G_ASSIGNED_TO', work, true);
|
|
190
|
-
const executions = connected(projection, nodeIds(deployment), 'G_EXECUTES_FOR', work, true);
|
|
191
|
-
const grants = connected(projection, nodeIds(tasks), 'G_HAS_GRANT', work);
|
|
192
|
-
const resources = connected(projection, nodeIds(grants), 'G_TARGETS', work);
|
|
193
|
-
const resourceBindings = connected(projection, nodeIds(resources), 'G_BINDS_SELECTOR', work, true);
|
|
194
|
-
const tools = [...connected(projection, nodeIds(grants), 'G_FOR_TOOL', work), ...connected(projection, nodeIds(executions), 'G_EXECUTES_TOOL', work)];
|
|
195
|
-
const connections = connected(projection, nodeIds(executions), 'G_VIA_CONNECTION', work), toolIds = nodeIds(tools);
|
|
196
|
-
const operations = connected(projection, nodeIds(connections), 'G_EXPOSES_OPERATION', work).filter(operation => connected(projection, new Set([operation.id]), 'G_OPERATION_TOOL', work).some(tool => toolIds.has(tool.id)));
|
|
197
|
-
const selected = nodeIds([agent, ...deployment, ...tasks, ...executions, ...grants, ...resources, ...resourceBindings,
|
|
198
|
-
...tools, ...connections, ...operations, ...connected(projection, nodeIds(tasks), 'G_ACTS_AS', work),
|
|
199
|
-
...connected(projection, nodeIds(executions), 'G_USES_IDENTITY', work), ...connected(projection, nodeIds(resourceBindings), 'G_MAPS_TO', work)]);
|
|
200
|
-
work.step(projection.nodes.length + projection.edges.length);
|
|
201
|
-
return { nodes: projection.nodes.filter(node => selected.has(node.id)),
|
|
202
|
-
edges: projection.edges.filter(edge => selected.has(edge.source) && selected.has(edge.target)) };
|
|
203
|
-
}
|
|
204
|
-
const POLICY_ACTIONS = Object.freeze({
|
|
205
|
-
's3:GetObject': 'CAN_READ', 's3:PutObject': 'CAN_WRITE', 's3:ListBucket': 'CAN_LIST',
|
|
206
|
-
});
|
|
207
|
-
const POLICY_REASONS = Object.freeze({
|
|
208
|
-
unavailable: ['binding_ambiguous', 'cloud_unresolved', 'provider_missing', 'collection_invalid',
|
|
209
|
-
'stale', 'changed', 'boundary_unresolved', 'unsupported_policy', 'evidence_invalid'],
|
|
210
|
-
not_collected: ['unsupported_action', 'identity_not_static', 'unsupported_binding',
|
|
211
|
-
'cloud_not_collected', 'collection_missing', 'disabled'],
|
|
212
|
-
});
|
|
213
|
-
const POLICY_FIELDS = ['taskId', 'deploymentId', 'executionBindingId', 'resourceBindingId',
|
|
214
|
-
'identityId', 'resourceId', 'action', 'status', 'reason', 'observedAt', 'assessment', 'candidates'].sort().join(',');
|
|
215
|
-
function policyObject(value) {
|
|
216
|
-
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
217
|
-
}
|
|
218
|
-
function policyTime(value) {
|
|
219
|
-
if (typeof value !== 'string')
|
|
220
|
-
return false;
|
|
221
|
-
const instant = Date.parse(value);
|
|
222
|
-
return Number.isFinite(instant) && instant >= 0 && instant <= 253_402_300_799_999 && new Date(instant).toISOString() === value;
|
|
223
|
-
}
|
|
224
|
-
function comparePolicyKeys(a, b) {
|
|
225
|
-
let left = 0, right = 0;
|
|
226
|
-
while (left < a.length && right < b.length) {
|
|
227
|
-
const ac = a.codePointAt(left), bc = b.codePointAt(right);
|
|
228
|
-
if (ac !== bc)
|
|
229
|
-
return ac - bc;
|
|
230
|
-
left += ac > 0xffff ? 2 : 1;
|
|
231
|
-
right += bc > 0xffff ? 2 : 1;
|
|
232
|
-
}
|
|
233
|
-
return (a.length - left) - (b.length - right);
|
|
234
|
-
}
|
|
235
|
-
/** Custom stores receive the same closed output contract and binding-path checks. */
|
|
236
|
-
function validatedPolicyEvidence(projection, work) {
|
|
237
|
-
const rawInput = projection.policyEvidence;
|
|
238
|
-
if (rawInput === undefined)
|
|
239
|
-
return [];
|
|
240
|
-
if (!Array.isArray(rawInput))
|
|
241
|
-
fail('MAP_UNAVAILABLE');
|
|
242
|
-
if (rawInput.length > 512)
|
|
243
|
-
fail('MAP_LIMIT_EXCEEDED');
|
|
244
|
-
let input;
|
|
245
|
-
try {
|
|
246
|
-
input = immutableJson(rawInput, 8, 100_000, 4 * 1024 * 1024);
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
return fail('MAP_UNAVAILABLE');
|
|
250
|
-
}
|
|
251
|
-
work.step(projection.nodes.length);
|
|
252
|
-
const entities = new Map(projection.nodes.map(node => [node.entityId, node]));
|
|
253
|
-
if (input.length && entities.size !== projection.nodes.length)
|
|
254
|
-
fail('MAP_UNAVAILABLE');
|
|
255
|
-
const seen = new Set(), result = [];
|
|
256
|
-
let materializedBytes = 0;
|
|
257
|
-
const link = (source, target, kind) => {
|
|
258
|
-
const targets = work.index(projection).outgoing.get(source.id)?.get(kind) ?? [];
|
|
259
|
-
work.step(targets.length);
|
|
260
|
-
return targets.includes(target.id);
|
|
261
|
-
};
|
|
262
|
-
const exactLink = (source, target, kind) => {
|
|
263
|
-
const targets = work.index(projection).outgoing.get(source.id)?.get(kind) ?? [];
|
|
264
|
-
work.step(targets.length);
|
|
265
|
-
return targets.length === 1 && targets[0] === target.id;
|
|
266
|
-
};
|
|
267
|
-
// Tasks may have several grants and grants several selectors, but the
|
|
268
|
-
// particular pair proving this record must not have duplicate relationships.
|
|
269
|
-
const uniqueLink = (source, target, kind) => {
|
|
270
|
-
const targets = work.index(projection).outgoing.get(source.id)?.get(kind) ?? [];
|
|
271
|
-
work.step(targets.length);
|
|
272
|
-
return targets.filter(id => id === target.id).length === 1;
|
|
273
|
-
};
|
|
274
|
-
const endpoint = (id, label) => {
|
|
275
|
-
mapIdentifier(id, 'MAP_UNAVAILABLE');
|
|
276
|
-
const node = entities.get(id);
|
|
277
|
-
if (!node || node.label !== label)
|
|
278
|
-
fail('MAP_UNAVAILABLE');
|
|
279
|
-
return node;
|
|
280
|
-
};
|
|
281
|
-
for (const raw of input) {
|
|
282
|
-
work.step();
|
|
283
|
-
if (!policyObject(raw) || Object.keys(raw).sort().join(',') !== POLICY_FIELDS ||
|
|
284
|
-
raw.assessment !== 'unknown' || typeof raw.action !== 'string' || raw.action.length > 4096 ||
|
|
285
|
-
!/^[a-z0-9][a-z0-9-]*:[A-Za-z][A-Za-z0-9]*$/.test(raw.action) ||
|
|
286
|
-
(raw.observedAt !== null && !policyTime(raw.observedAt)))
|
|
287
|
-
fail('MAP_UNAVAILABLE');
|
|
288
|
-
if (!Array.isArray(raw.candidates) || raw.candidates.length > 1)
|
|
289
|
-
fail('MAP_UNAVAILABLE');
|
|
290
|
-
if (raw.status === 'complete') {
|
|
291
|
-
if (!policyTime(raw.observedAt) || !POLICY_ACTIONS[raw.action] ||
|
|
292
|
-
raw.reason !== (raw.candidates.length ? null : 'no_candidate'))
|
|
293
|
-
fail('MAP_UNAVAILABLE');
|
|
294
|
-
}
|
|
295
|
-
else if ((raw.status !== 'unavailable' && raw.status !== 'not_collected') ||
|
|
296
|
-
!POLICY_REASONS[raw.status].includes(raw.reason) || raw.candidates.length)
|
|
297
|
-
fail('MAP_UNAVAILABLE');
|
|
298
|
-
for (const candidate of raw.candidates) {
|
|
299
|
-
if (!policyObject(candidate) || Object.keys(candidate).sort().join(',') !== 'conditionKeys,hasCondition,relationship,resourceScope' ||
|
|
300
|
-
candidate.relationship !== POLICY_ACTIONS[raw.action] || candidate.resourceScope !== 'bucket_summary' ||
|
|
301
|
-
typeof candidate.hasCondition !== 'boolean' || !Array.isArray(candidate.conditionKeys) || candidate.conditionKeys.length > 128)
|
|
302
|
-
fail('MAP_UNAVAILABLE');
|
|
303
|
-
for (const key of candidate.conditionKeys) {
|
|
304
|
-
work.step();
|
|
305
|
-
mapIdentifier(key, 'MAP_UNAVAILABLE');
|
|
306
|
-
if (Buffer.byteLength(key, 'utf8') > 256 || /[\u0000-\u001f]/.test(key))
|
|
307
|
-
fail('MAP_UNAVAILABLE');
|
|
308
|
-
}
|
|
309
|
-
if (candidate.hasCondition !== !!candidate.conditionKeys.length ||
|
|
310
|
-
JSON.stringify(candidate.conditionKeys) !== JSON.stringify([...new Set(candidate.conditionKeys)].sort(comparePolicyKeys)))
|
|
311
|
-
fail('MAP_UNAVAILABLE');
|
|
312
|
-
}
|
|
313
|
-
const task = endpoint(raw.taskId, 'GuardTask'), deployment = endpoint(raw.deploymentId, 'GuardDeployment');
|
|
314
|
-
const execution = endpoint(raw.executionBindingId, 'GuardExecutionBinding'), resource = endpoint(raw.resourceBindingId, 'GuardResourceBinding');
|
|
315
|
-
const tool = string(execution, 'tool');
|
|
316
|
-
if (!tool || string(resource, 'tool') !== tool || !strings(resource, 'actions').includes(raw.action) ||
|
|
317
|
-
!link(task, deployment, 'G_ASSIGNED_TO') || !link(execution, deployment, 'G_EXECUTES_FOR'))
|
|
318
|
-
fail('MAP_UNAVAILABLE');
|
|
319
|
-
const tools = connected(projection, new Set([execution.id]), 'G_EXECUTES_TOOL', work).filter(node => node.label === 'GuardTool' && string(node, 'name') === tool);
|
|
320
|
-
const grants = connected(projection, new Set([task.id]), 'G_HAS_GRANT', work).filter(node => node.label === 'GuardGrant' && string(node, 'tool') === tool);
|
|
321
|
-
const selectors = connected(projection, new Set([resource.id]), 'G_BINDS_SELECTOR', work).filter(node => node.label === 'GuardResource' && string(node, 'tool') === tool && string(node, 'resource_id') === string(resource, 'resource_ref'));
|
|
322
|
-
if (!grants.some(grant => strings(grant, 'resources').includes(string(resource, 'resource_ref') ?? '') &&
|
|
323
|
-
(raw.status !== 'complete' || uniqueLink(task, grant, 'G_HAS_GRANT')) &&
|
|
324
|
-
tools.some(toolNode => (raw.status === 'complete' ? exactLink : link)(grant, toolNode, 'G_FOR_TOOL')) &&
|
|
325
|
-
selectors.some(selector => (raw.status === 'complete' ? uniqueLink : link)(grant, selector, 'G_TARGETS'))))
|
|
326
|
-
fail('MAP_UNAVAILABLE');
|
|
327
|
-
for (const [key, label, relation, owner] of [
|
|
328
|
-
['identityId', 'GuardExternalIdentity', 'G_USES_IDENTITY', execution],
|
|
329
|
-
['resourceId', 'GuardExternalResource', 'G_MAPS_TO', resource],
|
|
330
|
-
]) {
|
|
331
|
-
if (raw[key] === null) {
|
|
332
|
-
if (raw.status === 'complete')
|
|
333
|
-
fail('MAP_UNAVAILABLE');
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
const target = endpoint(raw[key], label);
|
|
337
|
-
if (!(raw.status === 'complete' ? exactLink(owner, target, relation) : link(owner, target, relation)))
|
|
338
|
-
fail('MAP_UNAVAILABLE');
|
|
339
|
-
}
|
|
340
|
-
if (raw.status === 'complete') {
|
|
341
|
-
const agents = connected(projection, new Set([deployment.id]), 'G_INSTANCE_OF', work).filter(node => node.label === 'GuardAgent');
|
|
342
|
-
if (agents.length !== 1 || !exactLink(deployment, agents[0], 'G_INSTANCE_OF') ||
|
|
343
|
-
string(execution, 'identity_selection') !== 'static' ||
|
|
344
|
-
!exactLink(task, deployment, 'G_ASSIGNED_TO') || !exactLink(execution, deployment, 'G_EXECUTES_FOR') ||
|
|
345
|
-
tools.length !== 1 || !exactLink(execution, tools[0], 'G_EXECUTES_TOOL') || selectors.length !== 1 ||
|
|
346
|
-
!exactLink(resource, selectors[0], 'G_BINDS_SELECTOR'))
|
|
347
|
-
fail('MAP_UNAVAILABLE');
|
|
348
|
-
}
|
|
349
|
-
const identity = JSON.stringify([raw.taskId, raw.deploymentId, raw.executionBindingId, raw.resourceBindingId, raw.action]);
|
|
350
|
-
if (seen.has(identity))
|
|
351
|
-
fail('MAP_UNAVAILABLE');
|
|
352
|
-
seen.add(identity);
|
|
353
|
-
try {
|
|
354
|
-
const copy = immutableJson(raw, 6, 1024, 64 * 1024);
|
|
355
|
-
materializedBytes += Buffer.byteLength(JSON.stringify(copy));
|
|
356
|
-
if (materializedBytes > 4 * 1024 * 1024)
|
|
357
|
-
fail('MAP_LIMIT_EXCEEDED');
|
|
358
|
-
result.push(copy);
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
fail('MAP_LIMIT_EXCEEDED');
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return result;
|
|
365
|
-
}
|
|
366
|
-
function policyCoverage(records) {
|
|
367
|
-
const complete = records.filter(item => item.status === 'complete').length;
|
|
368
|
-
const observed = records.flatMap(item => item.observedAt === null ? [] : [item.observedAt]).sort();
|
|
369
|
-
return { status: !records.length || records.every(item => item.status === 'not_collected') ? 'not_collected' :
|
|
370
|
-
complete === records.length ? 'complete' : complete ? 'partial' : 'unavailable',
|
|
371
|
-
observedAt: observed[0] ?? null, assessment: 'unknown' };
|
|
372
|
-
}
|
|
373
|
-
function selectedPolicy(projection, work, options = {}) {
|
|
374
|
-
const ids = options.nodes ? new Set(options.nodes.map(node => node.entityId)) : undefined;
|
|
375
|
-
const executions = options.executions ? new Set(options.executions.map(node => node.entityId)) : undefined;
|
|
376
|
-
const resources = options.resources ? new Set(options.resources.map(node => node.entityId)) : undefined;
|
|
377
|
-
return work.policy(projection).filter(record => {
|
|
378
|
-
work.step();
|
|
379
|
-
return (!ids || [record.taskId, record.deploymentId, record.executionBindingId, record.resourceBindingId,
|
|
380
|
-
record.identityId, record.resourceId].every(id => id === null || ids.has(id))) &&
|
|
381
|
-
(!options.task || record.taskId === options.task.entityId) && (!executions || executions.has(record.executionBindingId)) &&
|
|
382
|
-
(!resources || resources.has(record.resourceBindingId)) && (!options.action || record.action === options.action);
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
function knownBlockers(task, tool, now) {
|
|
386
|
-
const blockers = [];
|
|
387
|
-
if (task.properties.revoked === true)
|
|
388
|
-
blockers.push('task_revoked');
|
|
389
|
-
const deadline = number(task, 'deadline');
|
|
390
|
-
if (deadline !== null && now >= deadline)
|
|
391
|
-
blockers.push('deadline_expired');
|
|
392
|
-
const calls = number(task, 'calls'), maxCalls = number(task, 'max_calls');
|
|
393
|
-
if (calls !== null && maxCalls !== null && calls >= maxCalls)
|
|
394
|
-
blockers.push('call_budget_exhausted');
|
|
395
|
-
const concurrent = number(task, 'concurrent'), maxConcurrent = number(task, 'max_concurrent');
|
|
396
|
-
if (concurrent !== null && maxConcurrent !== null && concurrent >= maxConcurrent)
|
|
397
|
-
blockers.push('concurrency_full_at_capture');
|
|
398
|
-
const spent = number(task, 'spent'), reserved = number(task, 'reserved'), maxCost = number(task, 'max_cost');
|
|
399
|
-
const cost = tool ? number(tool, 'max_cost') : null;
|
|
400
|
-
if (spent !== null && reserved !== null && maxCost !== null && cost !== null && spent + reserved + cost > maxCost)
|
|
401
|
-
blockers.push('cost_budget_exceeded');
|
|
402
|
-
if (tool?.properties.control_plane === true && task.properties.allow_control_plane === false)
|
|
403
|
-
blockers.push('control_plane_disabled');
|
|
404
|
-
return blockers;
|
|
405
|
-
}
|
|
406
|
-
function externalEvidence(projection, task, toolName, resourceId, work, policySelection = {}) {
|
|
407
|
-
const deployment = connected(projection, new Set([task.id]), 'G_ASSIGNED_TO', work);
|
|
408
|
-
const executions = connected(projection, nodeIds(deployment), 'G_EXECUTES_FOR', work, true)
|
|
409
|
-
.filter(node => string(node, 'tool') === toolName);
|
|
410
|
-
work.step(projection.nodes.length);
|
|
411
|
-
const resources = projection.nodes.filter(node => node.label === 'GuardResourceBinding' &&
|
|
412
|
-
string(node, 'tool') === toolName && string(node, 'resource_ref') === resourceId &&
|
|
413
|
-
(!policySelection.resourceBindingId || node.entityId === policySelection.resourceBindingId));
|
|
414
|
-
const policy = selectedPolicy(projection, work, { task, executions, resources,
|
|
415
|
-
...(policySelection.action ? { action: policySelection.action } : {}) });
|
|
416
|
-
return {
|
|
417
|
-
executionBindings: executions.map(node => ({ id: node.entityId, ...node.properties,
|
|
418
|
-
identities: connected(projection, new Set([node.id]), 'G_USES_IDENTITY', work).map(identity => ({ ...identity.properties })),
|
|
419
|
-
connections: connected(projection, new Set([node.id]), 'G_VIA_CONNECTION', work).map(connection => ({ ...connection.properties })) })),
|
|
420
|
-
resourceBindings: resources.map(node => ({ id: node.entityId, ...node.properties,
|
|
421
|
-
targets: connected(projection, new Set([node.id]), 'G_MAPS_TO', work).map(target => ({ ...target.properties })) })),
|
|
422
|
-
policyEvidence: policy, policyCoverage: policyCoverage(policy),
|
|
423
|
-
assessment: 'unknown',
|
|
424
|
-
limitations: ['Host bindings and provider inventory are evidence, not effective cloud permissions.',
|
|
425
|
-
'Container matches do not establish exact object or prefix access; missing evidence does not establish denial.'],
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
const COMMON_UNKNOWN = ['live_authorization', 'request_arguments_and_resolution', 'custom_policy', 'backend_authorization', 'action_approval'];
|
|
429
|
-
function explain(projection, scope, task, toolName, resourceId, now, work, policySelection = {}) {
|
|
430
|
-
const index = work.index(projection), nodes = index.nodes;
|
|
431
|
-
work.step(projection.nodes.length);
|
|
432
|
-
const tool = projection.nodes.find(node => node.label === 'GuardTool' && string(node, 'name') === toolName);
|
|
433
|
-
const blockers = [], matched = [], chain = [];
|
|
434
|
-
const unknown = [...COMMON_UNKNOWN];
|
|
435
|
-
if (!scope.allTeams)
|
|
436
|
-
unknown.push('ancestor_evidence_outside_visible_scope');
|
|
437
|
-
if (!tool || tool.properties.known_metadata === false || tool.properties.registered === false)
|
|
438
|
-
blockers.push('tool_unregistered_at_capture');
|
|
439
|
-
else if (tool.properties.registered !== true)
|
|
440
|
-
unknown.push('tool_registration');
|
|
441
|
-
if (tool?.properties.approved === false)
|
|
442
|
-
blockers.push('tool_metadata_unapproved');
|
|
443
|
-
else if (tool?.properties.approved !== true)
|
|
444
|
-
unknown.push('tool_metadata_approval');
|
|
445
|
-
const visited = new Set();
|
|
446
|
-
let current = task;
|
|
447
|
-
for (let depth = 0; current; depth++) {
|
|
448
|
-
work.step();
|
|
449
|
-
if (depth >= 16 || visited.has(current.id)) {
|
|
450
|
-
unknown.push('delegation_depth_or_cycle');
|
|
451
|
-
break;
|
|
452
|
-
}
|
|
453
|
-
visited.add(current.id);
|
|
454
|
-
chain.push({ id: current.entityId, taskId: string(current, 'task_id') });
|
|
455
|
-
blockers.push(...knownBlockers(current, tool, now));
|
|
456
|
-
const grants = connected(projection, new Set([current.id]), 'G_HAS_GRANT', work).filter(node => node.label === 'GuardGrant' && string(node, 'tool') === toolName &&
|
|
457
|
-
strings(node, 'resources').some(resource => resource === resourceId || resource === '*'));
|
|
458
|
-
if (!grants.length)
|
|
459
|
-
blockers.push(current.id === task.id ? 'declared_grant_missing' : 'ancestor_declared_grant_missing');
|
|
460
|
-
matched.push(...grants.map(grantSummary));
|
|
461
|
-
const parents = index.outgoing.get(current.id)?.get('G_DELEGATED_FROM') ?? [];
|
|
462
|
-
work.step(parents.length);
|
|
463
|
-
if (parents.length > 1) {
|
|
464
|
-
unknown.push('delegation_evidence_ambiguous');
|
|
465
|
-
break;
|
|
466
|
-
}
|
|
467
|
-
current = parents[0] ? nodes.get(parents[0]) : undefined;
|
|
468
|
-
}
|
|
469
|
-
if (task.properties.sensitive === true)
|
|
470
|
-
unknown.push('sensitive_egress_and_recipient_checks');
|
|
471
|
-
return { taskId: string(task, 'task_id'), tool: toolName, resourceId,
|
|
472
|
-
assessment: blockers.length ? 'blocked' : 'unknown', evidenceKind: 'declared_and_observed',
|
|
473
|
-
blockers: unique(blockers), unknownChecks: unique(unknown), delegation: chain, matchedGrants: matched,
|
|
474
|
-
externalEvidence: externalEvidence(projection, task, toolName, resourceId, work, policySelection),
|
|
475
|
-
limitations: ['Explanation only; no action was prepared or executed.', 'A clear exported gate does not establish effective permission.'] };
|
|
476
|
-
}
|
|
477
|
-
const IGNORE_DIFF = new Set(['lastupdated', 'manifest_observed_at', 'manifest_revision', 'observed_at', 'capture_observed_at', 'cloud_observed_at']);
|
|
478
|
-
function nodeState(node, projection, work) {
|
|
479
|
-
const index = work.index(projection);
|
|
480
|
-
const properties = Object.fromEntries(Object.entries(node.properties).filter(([key]) => !IGNORE_DIFF.has(key)));
|
|
481
|
-
const edges = [...(index.outgoing.get(node.id)?.entries() ?? [])].flatMap(([kind, targets]) => {
|
|
482
|
-
work.step(targets.length);
|
|
483
|
-
return targets.map(target => ({ kind, target: index.nodes.get(target)?.entityId ?? null }));
|
|
484
|
-
})
|
|
485
|
-
.sort((a, b) => `${a.kind}:${a.target}`.localeCompare(`${b.kind}:${b.target}`));
|
|
486
|
-
return canonicalJson({ properties, edges });
|
|
487
|
-
}
|
|
488
|
-
function freshness(observedAt, now, staleSeconds) {
|
|
489
|
-
const captured = typeof observedAt === 'string' ? Date.parse(observedAt) : NaN, age = now - captured;
|
|
490
|
-
return { stale: !Number.isFinite(captured) || age < -60_000 || age > staleSeconds * 1000,
|
|
491
|
-
ageSeconds: Number.isFinite(captured) ? Math.max(0, Math.floor(age / 1000)) : null };
|
|
492
|
-
}
|
|
493
|
-
function changeSummary(node) {
|
|
494
|
-
return { entityId: node.entityId, label: node.label,
|
|
495
|
-
name: string(node, 'name') ?? string(node, 'task_id') ?? string(node, 'agent_id') ?? string(node, 'deployment_id') ?? string(node, 'tool') ?? string(node, 'binding_id') ?? string(node, 'connection_id') ?? node.entityId };
|
|
496
|
-
}
|
|
36
|
+
/** Store errors a caller may see as they are; anything else reads as unavailable. */
|
|
37
|
+
const READ_ERRORS = ['MAP_NOT_FOUND', 'MAP_LIMIT_EXCEEDED', 'MAP_UNAVAILABLE', 'MAP_TIMEOUT', 'MAP_SCOPE_INVALID'];
|
|
497
38
|
/** One read-only evidence layer shared by HTTP, MCP and the browser. */
|
|
498
39
|
export class MapService {
|
|
499
40
|
#store;
|
|
@@ -504,15 +45,38 @@ export class MapService {
|
|
|
504
45
|
#timeout;
|
|
505
46
|
#staleSeconds;
|
|
506
47
|
constructor(store, options = {}) {
|
|
507
|
-
if (!store ||
|
|
48
|
+
if (!store ||
|
|
49
|
+
typeof store.read !== 'function' ||
|
|
50
|
+
(options.clock !== undefined && typeof options.clock !== 'function'))
|
|
508
51
|
fail('MAP_CONFIG_INVALID');
|
|
509
52
|
this.#store = store;
|
|
510
53
|
this.#clock = options.clock ?? Date.now;
|
|
511
|
-
this.#maxBytes = integer(options.maxResponseBytes, 1024 * 1024, 8 * 1024 * 1024
|
|
512
|
-
this.#maxNodes = integer(options.maxGraphNodes, 500, 2000);
|
|
513
|
-
this.#maxEdges = integer(options.maxGraphEdges, 1000, 5000);
|
|
514
|
-
this.#timeout = integer(options.queryTimeoutMs, 5000,
|
|
515
|
-
this.#staleSeconds = integer(options.staleAfterSeconds, 900, 86400);
|
|
54
|
+
this.#maxBytes = integer(options.maxResponseBytes, 1024 * 1024, 128, 8 * 1024 * 1024);
|
|
55
|
+
this.#maxNodes = integer(options.maxGraphNodes, 500, 1, 2000);
|
|
56
|
+
this.#maxEdges = integer(options.maxGraphEdges, 1000, 1, 5000);
|
|
57
|
+
this.#timeout = integer(options.queryTimeoutMs, 5000, 10, 10_000);
|
|
58
|
+
this.#staleSeconds = integer(options.staleAfterSeconds, 900, 1, 86400);
|
|
59
|
+
}
|
|
60
|
+
/** One store read under the caller's scope, bounded by `timeout`. */
|
|
61
|
+
async #read(scope, request, timeout) {
|
|
62
|
+
let timer;
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([
|
|
65
|
+
this.#store.read(scope, request),
|
|
66
|
+
new Promise((_resolve, reject) => {
|
|
67
|
+
timer = setTimeout(() => reject(new MapQueryError('MAP_TIMEOUT')), timeout);
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof MapQueryError && READ_ERRORS.includes(error.code))
|
|
73
|
+
throw error;
|
|
74
|
+
return fail('MAP_UNAVAILABLE');
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
if (timer)
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
516
80
|
}
|
|
517
81
|
async query(inputScope, inputOperation, inputArgs) {
|
|
518
82
|
const scope = validateReadScope(inputScope);
|
|
@@ -528,155 +92,56 @@ export class MapService {
|
|
|
528
92
|
}
|
|
529
93
|
if (!Number.isSafeInteger(now) || now < 0 || now > 253_402_300_799_999)
|
|
530
94
|
fail('MAP_UNAVAILABLE');
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
let timer;
|
|
536
|
-
let view;
|
|
537
|
-
try {
|
|
538
|
-
view = await Promise.race([this.#store.read(scope, request), new Promise((_resolve, reject) => {
|
|
539
|
-
timer = setTimeout(() => reject(new MapQueryError('MAP_TIMEOUT')), this.#timeout);
|
|
540
|
-
})]);
|
|
541
|
-
}
|
|
542
|
-
catch (error) {
|
|
543
|
-
if (error instanceof MapQueryError && ['MAP_NOT_FOUND', 'MAP_LIMIT_EXCEEDED', 'MAP_UNAVAILABLE', 'MAP_TIMEOUT', 'MAP_SCOPE_INVALID'].includes(error.code))
|
|
544
|
-
throw error;
|
|
545
|
-
return fail('MAP_UNAVAILABLE');
|
|
546
|
-
}
|
|
547
|
-
finally {
|
|
548
|
-
if (timer)
|
|
549
|
-
clearTimeout(timer);
|
|
550
|
-
}
|
|
95
|
+
const handler = OPERATIONS[operation];
|
|
96
|
+
const read = (request, timeout = this.#timeout) => this.#read(scope, request, timeout);
|
|
97
|
+
const request = handler.request(args);
|
|
98
|
+
const view = request === null ? { sources: [] } : await read(request);
|
|
551
99
|
work.step(view.sources.length);
|
|
552
|
-
|
|
553
|
-
let graph;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const limit = args.limit ?? 50;
|
|
565
|
-
data = { agents: agents.slice(0, limit), truncated: agents.length > limit };
|
|
566
|
-
}
|
|
567
|
-
else if (operation === 'get_agent_access') {
|
|
568
|
-
const source = findSource(view, args.sourceId), projection = source.current;
|
|
569
|
-
const agent = findAgent(projection, args.agentId);
|
|
570
|
-
graph = focus(projection, agent, work);
|
|
571
|
-
const policy = selectedPolicy(projection, work, { nodes: graph.nodes });
|
|
572
|
-
data = { agent: agentSummary(source, projection, agent, work),
|
|
573
|
-
deployments: graph.nodes.filter(node => node.label === 'GuardDeployment').map(node => ({ id: node.entityId, deploymentId: string(node, 'deployment_id'), environment: string(node, 'environment') })),
|
|
574
|
-
tasks: graph.nodes.filter(node => node.label === 'GuardTask').map(taskSummary),
|
|
575
|
-
grants: graph.nodes.filter(node => node.label === 'GuardGrant').map(grantSummary),
|
|
576
|
-
connections: graph.nodes.filter(node => node.label === 'GuardMcpConnection').map(node => ({ id: node.entityId, ...node.properties })),
|
|
577
|
-
bindings: graph.nodes.filter(node => ['GuardExecutionBinding', 'GuardResourceBinding'].includes(node.label)).map(node => ({ id: node.entityId, kind: node.label, ...node.properties })),
|
|
578
|
-
policyEvidence: policy, policyCoverage: policyCoverage(policy),
|
|
579
|
-
receipt: receipt(source), assessment: 'unknown',
|
|
580
|
-
limitations: ['Declared and observed evidence only; current executor admission is required.', 'External object and prefix bindings retain their original scope.'] };
|
|
100
|
+
const result = await handler.run({ scope, args, now, work, maxBytes: this.#maxBytes, staleSeconds: this.#staleSeconds, read }, view);
|
|
101
|
+
let { data, graph } = result;
|
|
102
|
+
// The focused graph is an optional picture of evidence the response already lists. Past the
|
|
103
|
+
// display cap it is left out and `graphStatus` says so; the evidence itself still answers.
|
|
104
|
+
const withoutGraph = (payload) => ({
|
|
105
|
+
...payload,
|
|
106
|
+
graphStatus: 'too_large',
|
|
107
|
+
graphLimit: { nodes: this.#maxNodes, edges: this.#maxEdges },
|
|
108
|
+
});
|
|
109
|
+
if (graph && (graph.nodes.length > this.#maxNodes || graph.edges.length > this.#maxEdges)) {
|
|
110
|
+
graph = undefined;
|
|
111
|
+
data = withoutGraph(data);
|
|
581
112
|
}
|
|
582
|
-
else if (operation === 'explain_access') {
|
|
583
|
-
const source = findSource(view, args.sourceId);
|
|
584
|
-
const task = source.current.nodes.find(node => node.label === 'GuardTask' && matching(node, args.taskId, 'task_id'));
|
|
585
|
-
if (!task)
|
|
586
|
-
fail('MAP_NOT_FOUND');
|
|
587
|
-
data = { ...explain(source.current, scope, task, args.tool, args.resourceId, now, work), receipt: receipt(source) };
|
|
588
|
-
}
|
|
589
|
-
else if (operation === 'find_agents_with_access') {
|
|
590
|
-
const candidates = [];
|
|
591
|
-
const limit = args.limit ?? 50;
|
|
592
|
-
let truncated = false;
|
|
593
|
-
candidateSearch: for (const source of view.sources)
|
|
594
|
-
for (const agent of source.current.nodes.filter(node => node.label === 'GuardAgent')) {
|
|
595
|
-
work.step();
|
|
596
|
-
const selected = focus(source.current, agent, work), selectedProjection = { ...source.current, ...selected };
|
|
597
|
-
const bindings = selected.nodes.filter(node => node.label === 'GuardResourceBinding' && strings(node, 'actions').includes(args.action));
|
|
598
|
-
const evidence = [];
|
|
599
|
-
for (const binding of bindings) {
|
|
600
|
-
work.step();
|
|
601
|
-
const external = connected(selectedProjection, new Set([binding.id]), 'G_MAPS_TO', work)[0];
|
|
602
|
-
if (string(binding, 'resource_ref') !== args.resourceRef && (!external || string(external, 'arn') !== args.resourceRef))
|
|
603
|
-
continue;
|
|
604
|
-
const toolName = string(binding, 'tool'), resource = string(binding, 'resource_ref');
|
|
605
|
-
if (!toolName || !resource)
|
|
606
|
-
continue;
|
|
607
|
-
for (const task of selected.nodes.filter(node => node.label === 'GuardTask')) {
|
|
608
|
-
work.step();
|
|
609
|
-
if (!connected(selectedProjection, new Set([task.id]), 'G_HAS_GRANT', work).some(grant => string(grant, 'tool') === toolName && strings(grant, 'resources').some(item => item === '*' || item === resource)))
|
|
610
|
-
continue;
|
|
611
|
-
if (candidates.length >= limit) {
|
|
612
|
-
truncated = true;
|
|
613
|
-
break candidateSearch;
|
|
614
|
-
}
|
|
615
|
-
const deployment = connected(selectedProjection, new Set([task.id]), 'G_ASSIGNED_TO', work);
|
|
616
|
-
const executions = connected(selectedProjection, nodeIds(deployment), 'G_EXECUTES_FOR', work, true).filter(node => string(node, 'tool') === toolName);
|
|
617
|
-
const policy = selectedPolicy(source.current, work, { task, executions, resources: [binding], action: args.action });
|
|
618
|
-
evidence.push(work.evidence({ taskId: string(task, 'task_id'), tool: toolName, resourceRef: resource, action: args.action,
|
|
619
|
-
scope: string(binding, 'scope'), externalResource: external ? { ...external.properties } : null,
|
|
620
|
-
executionBindings: executions.map(node => ({ id: node.entityId, ...node.properties,
|
|
621
|
-
identities: connected(selectedProjection, new Set([node.id]), 'G_USES_IDENTITY', work).map(identity => ({ ...identity.properties })) })),
|
|
622
|
-
policyEvidence: policy, policyCoverage: policyCoverage(policy),
|
|
623
|
-
explanation: explain(source.current, scope, task, toolName, resource, now, work, { action: args.action, resourceBindingId: binding.entityId }) }));
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
if (evidence.length)
|
|
627
|
-
candidates.push({ agent: agentSummary(source, source.current, agent, work), evidence,
|
|
628
|
-
assessment: 'unknown', limitations: ['Potential access candidates; no effective permission decision.', 'Cloud containment is not exact object authorization.'] });
|
|
629
|
-
}
|
|
630
|
-
data = { candidates, truncated };
|
|
631
|
-
}
|
|
632
|
-
else if (operation === 'get_changes') {
|
|
633
|
-
const source = findSource(view, args.sourceId), current = source.current;
|
|
634
|
-
const since = args.since ?? 'previous';
|
|
635
|
-
const previous = [current.id, current.snapshotId].includes(since) ? current :
|
|
636
|
-
source.previous && (since === 'previous' || [source.previous.id, source.previous.snapshotId].includes(since)) ? source.previous : null;
|
|
637
|
-
const added = [], removed = [], changed = [];
|
|
638
|
-
if (previous) {
|
|
639
|
-
const before = new Map(previous.nodes.map(node => [node.entityId, node])), after = new Map(current.nodes.map(node => [node.entityId, node]));
|
|
640
|
-
for (const [id, node] of after) {
|
|
641
|
-
work.step();
|
|
642
|
-
if (!before.has(id))
|
|
643
|
-
added.push(changeSummary(node));
|
|
644
|
-
else if (nodeState(node, current, work) !== nodeState(before.get(id), previous, work))
|
|
645
|
-
changed.push(changeSummary(node));
|
|
646
|
-
}
|
|
647
|
-
for (const [id, node] of before)
|
|
648
|
-
if (!after.has(id))
|
|
649
|
-
removed.push(changeSummary(node));
|
|
650
|
-
}
|
|
651
|
-
data = { sourceId: source.id, from: previous ? receipt(source, previous) : null, to: receipt(source),
|
|
652
|
-
changes: { added, removed, changed }, coverageGap: previous === null, policyHistory: 'not_collected',
|
|
653
|
-
limitations: ['Net changes in authorized retained snapshots only; not an invocation or intermediate-event log.',
|
|
654
|
-
'Current IAM policy observations are separate from retained Guard snapshots; policy history is not collected.'] };
|
|
655
|
-
}
|
|
656
|
-
else {
|
|
657
|
-
data = { sources: view.sources.map(source => {
|
|
658
|
-
work.step();
|
|
659
|
-
return { id: source.id, sourceId: source.sourceId, tenantId: source.tenantId, profile: source.profile,
|
|
660
|
-
generatedAt: source.current.generatedAt, snapshotId: source.current.snapshotId, projectionId: source.current.id,
|
|
661
|
-
...freshness(source.current.generatedAt, now, this.#staleSeconds),
|
|
662
|
-
coverage: { ...Object.fromEntries(Object.entries(source.current.coverage).filter(([component]) => component !== 'policy').map(([component, status]) => {
|
|
663
|
-
work.step();
|
|
664
|
-
return [component, { ...status, ...freshness(status.observedAt, now, this.#staleSeconds) }];
|
|
665
|
-
})), policy: policyCoverage(work.policy(source.current)) } };
|
|
666
|
-
}), visibility: scope.allTeams ? 'enrolled_sources' : 'visible_agent_sources',
|
|
667
|
-
limitations: ['Collection freshness is separate from current execution authority.'] };
|
|
668
|
-
}
|
|
669
|
-
if (graph && (graph.nodes.length > this.#maxNodes || graph.edges.length > this.#maxEdges))
|
|
670
|
-
fail('MAP_LIMIT_EXCEEDED');
|
|
671
113
|
work.step();
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
114
|
+
const observedAt = new Date(now).toISOString();
|
|
115
|
+
const freeze = (payload) => {
|
|
116
|
+
try {
|
|
117
|
+
// The copy is the response: `operation`, `observedAt`, `data` and, when kept, `graph`.
|
|
118
|
+
return immutableJson({ operation, observedAt, data: payload, ...(graph ? { graph } : {}) }, 32, 100_000, this.#maxBytes);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
// The identity-scope bucket lists are optional detail. If the response does not fit with
|
|
125
|
+
// them, shorten them (marked truncated) before giving up on the whole response.
|
|
126
|
+
const fitted = (payload) => {
|
|
127
|
+
let copied = freeze(payload);
|
|
128
|
+
for (const keep of [25, 5, 0]) {
|
|
129
|
+
if (copied)
|
|
130
|
+
break;
|
|
131
|
+
const shortened = shortenIdentityScope(payload, keep);
|
|
132
|
+
if (shortened)
|
|
133
|
+
copied = freeze(shortened);
|
|
134
|
+
}
|
|
135
|
+
return copied;
|
|
136
|
+
};
|
|
137
|
+
let copied = fitted(data);
|
|
138
|
+
// Still too large with the graph: answer with the evidence alone.
|
|
139
|
+
if (!copied && graph) {
|
|
140
|
+
graph = undefined;
|
|
141
|
+
copied = fitted(withoutGraph(data));
|
|
676
142
|
}
|
|
677
|
-
|
|
143
|
+
if (!copied)
|
|
678
144
|
return fail('MAP_LIMIT_EXCEEDED');
|
|
679
|
-
}
|
|
680
145
|
work.step();
|
|
681
146
|
return copied;
|
|
682
147
|
}
|