@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,70 @@
|
|
|
1
|
+
import { DANGEROUS_THREE_LIMITATIONS, dangerousThree } from '../boundary-flags.js';
|
|
2
|
+
import { MapQueryError } from '../types.js';
|
|
3
|
+
import { jsonObject } from './json.js';
|
|
4
|
+
import { connected, number, QueryWork, string } from './work.js';
|
|
5
|
+
/**
|
|
6
|
+
* The bounded, read-only graph view the boundary flags use. Sensitive bindings for `*` grants
|
|
7
|
+
* come from the store's wildcard read when it ran (one-agent boundary reads); a team-wide read
|
|
8
|
+
* already holds every in-scope binding, indexed once per projection.
|
|
9
|
+
*/
|
|
10
|
+
export const sensitiveIndex = new WeakMap();
|
|
11
|
+
export function flagGraph(projection, work) {
|
|
12
|
+
return {
|
|
13
|
+
linked: (node, kind, reverse = false) => connected(projection, new Set([node.id]), kind, work, reverse),
|
|
14
|
+
sensitiveBindings: tool => {
|
|
15
|
+
if (projection.wildcardSensitivity)
|
|
16
|
+
return Object.hasOwn(projection.wildcardSensitivity, tool) ? projection.wildcardSensitivity[tool] : [];
|
|
17
|
+
let index = sensitiveIndex.get(projection);
|
|
18
|
+
if (!index) {
|
|
19
|
+
work.step(projection.nodes.length);
|
|
20
|
+
index = new Map();
|
|
21
|
+
for (const node of projection.nodes) {
|
|
22
|
+
const name = string(node, 'tool'), sensitivity = string(node, 'sensitivity');
|
|
23
|
+
if (node.label !== 'GuardResourceBinding' || name === null)
|
|
24
|
+
continue;
|
|
25
|
+
if (sensitivity !== 'confidential' && sensitivity !== 'restricted')
|
|
26
|
+
continue;
|
|
27
|
+
const items = index.get(name) ?? [];
|
|
28
|
+
items.push({ id: node.entityId, sensitivity });
|
|
29
|
+
index.set(name, items);
|
|
30
|
+
}
|
|
31
|
+
for (const [name, items] of index)
|
|
32
|
+
index.set(name, items.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).slice(0, 5));
|
|
33
|
+
sensitiveIndex.set(projection, index);
|
|
34
|
+
}
|
|
35
|
+
return index.get(tool) ?? [];
|
|
36
|
+
},
|
|
37
|
+
step: amount => work.step(amount),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Task expiry is judged at the snapshot's own capture time, so list and detail agree. */
|
|
41
|
+
export function agentFlags(projection, agent, scope, work) {
|
|
42
|
+
return dangerousThree(flagGraph(projection, work), agent, Date.parse(projection.generatedAt), scope.allTeams, projection.ancestorsNotLoaded === true);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The detail flag with its own work budget: a very large lineage degrades the flag to
|
|
46
|
+
* `unknown`, it never fails the rest of get_agent_access.
|
|
47
|
+
*/
|
|
48
|
+
export function detailFlags(projection, agent, scope, remaining, maxBytes) {
|
|
49
|
+
try {
|
|
50
|
+
return jsonObject(agentFlags(projection, agent, scope, new QueryWork(Math.max(1, remaining - 25), maxBytes)));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const code = error instanceof MapQueryError ? error.code : '';
|
|
54
|
+
if (code !== 'MAP_LIMIT_EXCEEDED' && code !== 'MAP_TIMEOUT')
|
|
55
|
+
throw error;
|
|
56
|
+
return jsonObject({
|
|
57
|
+
kind: 'dangerous_three',
|
|
58
|
+
status: 'unknown',
|
|
59
|
+
taskId: null,
|
|
60
|
+
legs: { privateData: 'unknown', outsideContent: 'unknown', externalSend: 'unknown' },
|
|
61
|
+
evidence: [],
|
|
62
|
+
evidenceTruncated: false,
|
|
63
|
+
notes: [code === 'MAP_TIMEOUT' ? 'flags_timeout' : 'flags_limit_exceeded'],
|
|
64
|
+
liveTasks: 0,
|
|
65
|
+
evidenceKind: 'declared',
|
|
66
|
+
assessment: 'unknown',
|
|
67
|
+
limitations: DANGEROUS_THREE_LIMITATIONS,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { JsonObject, MapNode, MapProjection, MapSource } from '../types.js';
|
|
2
|
+
import { sections } from './schemas.js';
|
|
3
|
+
import { QueryWork } from './work.js';
|
|
4
|
+
export declare function inventorySections(source: MapSource, projection: MapProjection, agent: MapNode, work: QueryWork): Record<(typeof sections)[number], JsonObject[]>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { safeProperties } from '../projection-fields.js';
|
|
2
|
+
import { ordered } from './ordering.js';
|
|
3
|
+
import { sections } from './schemas.js';
|
|
4
|
+
import { agentSummary, deployments, grantSummary, hostObservation, ownerSummary, relationshipRows, resourceSummary, taskSummary, } from './summaries.js';
|
|
5
|
+
import { connected, number, QueryWork, string, strings } from './work.js';
|
|
6
|
+
export function inventorySections(source, projection, agent, work) {
|
|
7
|
+
const byLabel = (label) => projection.nodes.filter(node => node.label === label);
|
|
8
|
+
const described = (label) => byLabel(label).map(node => ({ id: node.entityId, ...safeProperties(node) }));
|
|
9
|
+
const summary = agentSummary(source, projection, agent, work);
|
|
10
|
+
return {
|
|
11
|
+
overview: [
|
|
12
|
+
{
|
|
13
|
+
purpose: summary.purpose,
|
|
14
|
+
lifecycleState: summary.lifecycleState,
|
|
15
|
+
ownershipStatus: summary.ownershipStatus,
|
|
16
|
+
ownershipSource: summary.ownershipSource,
|
|
17
|
+
ownerName: summary.ownerName,
|
|
18
|
+
...hostObservation(agent),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
owners: ordered(byLabel('GuardOwner').map(node => ownerSummary(source, projection, node, work, false))),
|
|
22
|
+
deployments: ordered(byLabel('GuardDeployment').map(node => ({
|
|
23
|
+
id: node.entityId,
|
|
24
|
+
deploymentId: string(node, 'deployment_id'),
|
|
25
|
+
environment: string(node, 'environment'),
|
|
26
|
+
runtimeRef: string(node, 'runtime_ref'),
|
|
27
|
+
workloadGeneration: string(node, 'workload_generation'),
|
|
28
|
+
buildRef: string(node, 'build_ref'),
|
|
29
|
+
bindingAuthority: string(node, 'binding_authority'),
|
|
30
|
+
// The same rule as get_agent_access: `found` needs an entry in the read.
|
|
31
|
+
directAccessStatus: connected(projection, new Set([node.id]), 'G_DIRECT_ACCESS_FOR', work, true).length
|
|
32
|
+
? 'found'
|
|
33
|
+
: string(node, 'direct_access_status') === 'none_declared'
|
|
34
|
+
? 'none_declared'
|
|
35
|
+
: 'unknown',
|
|
36
|
+
...hostObservation(node),
|
|
37
|
+
}))),
|
|
38
|
+
tasks: ordered(byLabel('GuardTask').map(taskSummary)),
|
|
39
|
+
permissions: ordered(byLabel('GuardGrant').map(grantSummary)),
|
|
40
|
+
resources: ordered(byLabel('GuardResourceBinding').map(node => resourceSummary(source, projection, node, work))),
|
|
41
|
+
connections: ordered(described('GuardMcpConnection')),
|
|
42
|
+
credentials: ordered(byLabel('GuardCredentialReference').map(node => ({
|
|
43
|
+
id: node.entityId,
|
|
44
|
+
credentialId: string(node, 'credential_id'),
|
|
45
|
+
name: string(node, 'name'),
|
|
46
|
+
issuerRef: string(node, 'issuer_ref'),
|
|
47
|
+
audienceRefs: strings(node, 'audience_refs'),
|
|
48
|
+
recipientRefs: strings(node, 'recipient_refs'),
|
|
49
|
+
lifecycleState: string(node, 'lifecycle_state') ?? 'unknown',
|
|
50
|
+
expiresAt: string(node, 'expires_at'),
|
|
51
|
+
rotationStatus: string(node, 'rotation_status') ?? 'unknown',
|
|
52
|
+
...hostObservation(node),
|
|
53
|
+
}))),
|
|
54
|
+
relationships: relationshipRows(projection, work),
|
|
55
|
+
tools: ordered(described('GuardTool')),
|
|
56
|
+
bindings: ordered(projection.nodes
|
|
57
|
+
.filter(node => ['GuardExecutionBinding', 'GuardResourceBinding'].includes(node.label))
|
|
58
|
+
.map(node => ({ id: node.entityId, kind: node.label, ...safeProperties(node) }))),
|
|
59
|
+
identities: ordered(described('GuardExternalIdentity')),
|
|
60
|
+
policies: [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { JsonObject } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The map's record interfaces (`MapCoverage`, `MapPolicyEvidence`, flag results and so on) hold
|
|
4
|
+
* only JSON values, but an interface has no index signature, so TypeScript will not treat one as
|
|
5
|
+
* a `JsonObject`. These two helpers are the only place that says so.
|
|
6
|
+
*/
|
|
7
|
+
export declare const jsonObject: (value: object) => JsonObject;
|
|
8
|
+
export declare const jsonList: (values: readonly object[]) => readonly JsonObject[];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The map's record interfaces (`MapCoverage`, `MapPolicyEvidence`, flag results and so on) hold
|
|
3
|
+
* only JSON values, but an interface has no index signature, so TypeScript will not treat one as
|
|
4
|
+
* a `JsonObject`. These two helpers are the only place that says so.
|
|
5
|
+
*/
|
|
6
|
+
export const jsonObject = (value) => value;
|
|
7
|
+
export const jsonList = (values) => values;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Operation } from '../context.js';
|
|
2
|
+
/** Tasks or caller principals of one source that no declared agent accounts for. */
|
|
3
|
+
export declare const listUndeclaredActivity: Operation;
|
|
4
|
+
/** IAM roles and users of the enrolled AWS accounts that no declared agent names. */
|
|
5
|
+
export declare const listUnclaimedIdentities: Operation;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fail } from '../../common.js';
|
|
2
|
+
import { notVisible, UNCLAIMED_LIMITATIONS, UNDECLARED_LIMITATIONS, undeclaredCounts } from '../discovery.js';
|
|
3
|
+
import { page } from '../ordering.js';
|
|
4
|
+
import { pageArguments } from '../schemas.js';
|
|
5
|
+
import { findSource, freshness, receipt } from '../summaries.js';
|
|
6
|
+
import { string } from '../work.js';
|
|
7
|
+
// Discovery operations use their own bounded reads, and none at all for a team-scoped reader.
|
|
8
|
+
/** Tasks or caller principals of one source that no declared agent accounts for. */
|
|
9
|
+
export const listUndeclaredActivity = {
|
|
10
|
+
request: () => null,
|
|
11
|
+
async run({ scope, args, work, read }) {
|
|
12
|
+
if (!scope.allTeams)
|
|
13
|
+
return { data: notVisible() };
|
|
14
|
+
const kind = (args.kind ?? 'tasks');
|
|
15
|
+
const { offset, limit } = pageArguments(args);
|
|
16
|
+
const found = await read({ sourceId: args.sourceId, undeclared: true, page: { kind, offset, limit } });
|
|
17
|
+
const source = findSource(found, args.sourceId);
|
|
18
|
+
const activity = source.current.undeclared;
|
|
19
|
+
if (!activity || activity.page?.kind !== kind)
|
|
20
|
+
fail('MAP_UNAVAILABLE');
|
|
21
|
+
work.step(activity.page.items.length);
|
|
22
|
+
const total = kind === 'tasks' ? activity.tasks : activity.principals;
|
|
23
|
+
const minimum = kind === 'tasks' ? activity.tasksCapped : activity.principalsCapped;
|
|
24
|
+
return {
|
|
25
|
+
data: {
|
|
26
|
+
visibility: 'all_teams',
|
|
27
|
+
kind,
|
|
28
|
+
items: activity.page.items.map(item => ({ ...item, evidenceKind: 'observed', assessment: 'unknown' })),
|
|
29
|
+
total,
|
|
30
|
+
totalIsMinimum: minimum,
|
|
31
|
+
offset,
|
|
32
|
+
limit,
|
|
33
|
+
truncated: minimum || offset + limit < total,
|
|
34
|
+
counts: undeclaredCounts(source, activity),
|
|
35
|
+
receipt: receipt(source),
|
|
36
|
+
assessment: 'unknown',
|
|
37
|
+
limitations: UNDECLARED_LIMITATIONS,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/** IAM roles and users of the enrolled AWS accounts that no declared agent names. */
|
|
43
|
+
export const listUnclaimedIdentities = {
|
|
44
|
+
request: () => null,
|
|
45
|
+
async run({ scope, args, now, work, staleSeconds, read }) {
|
|
46
|
+
if (!scope.allTeams)
|
|
47
|
+
return { data: notVisible() };
|
|
48
|
+
const { offset, limit } = pageArguments(args);
|
|
49
|
+
const found = await read({ unclaimedIdentities: true, page: { offset, limit } });
|
|
50
|
+
const result = found.unclaimedIdentities;
|
|
51
|
+
if (!result)
|
|
52
|
+
fail('MAP_UNAVAILABLE');
|
|
53
|
+
work.step(result.accounts.length + result.identities.length + found.sources.length);
|
|
54
|
+
const complete = result.accounts.filter(account => account.status === 'complete').length;
|
|
55
|
+
// No usable collection: nothing is known, so no total and no list. Some unusable: the
|
|
56
|
+
// total and the list cover the complete accounts only.
|
|
57
|
+
const status = !complete ? 'unknown' : complete === result.accounts.length ? 'complete' : 'partial';
|
|
58
|
+
return {
|
|
59
|
+
data: {
|
|
60
|
+
visibility: 'all_teams',
|
|
61
|
+
status,
|
|
62
|
+
reason: result.accounts.length ? null : 'cloud_not_collected',
|
|
63
|
+
identities: result.identities.map(identity => ({
|
|
64
|
+
...identity,
|
|
65
|
+
candidate: true,
|
|
66
|
+
evidenceKind: 'provider_inventory',
|
|
67
|
+
assessment: 'unknown',
|
|
68
|
+
})),
|
|
69
|
+
total: status === 'unknown' ? null : result.total,
|
|
70
|
+
offset,
|
|
71
|
+
limit,
|
|
72
|
+
truncated: status !== 'unknown' && offset + limit < result.total,
|
|
73
|
+
accounts: result.accounts.map(account => ({
|
|
74
|
+
...account,
|
|
75
|
+
...(account.observedAt ? freshness(account.observedAt, now, staleSeconds) : {}),
|
|
76
|
+
})),
|
|
77
|
+
bindingsWithoutIdentity: result.bindingsWithoutIdentity,
|
|
78
|
+
// The snapshots whose declared bindings decided what counts as claimed.
|
|
79
|
+
declaredFrom: found.sources.map(source => ({
|
|
80
|
+
sourceId: source.id,
|
|
81
|
+
tenantId: source.tenantId,
|
|
82
|
+
snapshotId: source.current.snapshotId,
|
|
83
|
+
generatedAt: source.current.generatedAt,
|
|
84
|
+
})),
|
|
85
|
+
assessment: 'unknown',
|
|
86
|
+
limitations: UNCLAIMED_LIMITATIONS,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { fail } from '../../common.js';
|
|
2
|
+
import { sourceSelection } from '../context.js';
|
|
3
|
+
import { explain } from '../explain.js';
|
|
4
|
+
import { findSource, receipt } from '../summaries.js';
|
|
5
|
+
import { matching, string } from '../work.js';
|
|
6
|
+
/** Why one task may or may not use one tool on one resource selector, as far as the snapshot says. */
|
|
7
|
+
export const explainAccess = {
|
|
8
|
+
// The policy evidence is narrowed to this task, tool and selector.
|
|
9
|
+
request: args => ({
|
|
10
|
+
...sourceSelection(args),
|
|
11
|
+
policy: { taskId: args.taskId, tool: args.tool, resourceRef: args.resourceId },
|
|
12
|
+
}),
|
|
13
|
+
run({ scope, args, now, work }, view) {
|
|
14
|
+
const source = findSource(view, args.sourceId);
|
|
15
|
+
const task = source.current.nodes.find(node => node.label === 'GuardTask' && matching(node, args.taskId, 'task_id'));
|
|
16
|
+
if (!task)
|
|
17
|
+
fail('MAP_NOT_FOUND');
|
|
18
|
+
return {
|
|
19
|
+
data: {
|
|
20
|
+
...explain(source.current, scope, task, args.tool, args.resourceId, now, work),
|
|
21
|
+
receipt: receipt(source),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { safeProperties } from '../../projection-fields.js';
|
|
2
|
+
import { MapQueryError } from '../../types.js';
|
|
3
|
+
import { sourceSelection } from '../context.js';
|
|
4
|
+
import { explain } from '../explain.js';
|
|
5
|
+
import { jsonList } from '../json.js';
|
|
6
|
+
import { policyCoverage, policyCoverageOf, selectedPolicy } from '../policy.js';
|
|
7
|
+
import { agentSummary, focus } from '../summaries.js';
|
|
8
|
+
import { connected, nodeIds, number, QueryWork, string, strings } from '../work.js';
|
|
9
|
+
/** Agents whose declared grants and host bindings map to one resource reference and cloud action. */
|
|
10
|
+
export const findAgentsWithAccess = {
|
|
11
|
+
// A resource most of the fleet uses can match more pairs than one read evaluates: the
|
|
12
|
+
// candidates are then listed with policy coverage unknown instead of failing.
|
|
13
|
+
request: args => ({
|
|
14
|
+
...sourceSelection(args),
|
|
15
|
+
policy: { resourceRef: args.resourceRef, action: args.action, optional: true },
|
|
16
|
+
}),
|
|
17
|
+
run({ scope, args, now, work, maxBytes }, view) {
|
|
18
|
+
const candidates = [];
|
|
19
|
+
const limit = args.limit ?? 50;
|
|
20
|
+
let truncated = false;
|
|
21
|
+
let truncatedReason = null;
|
|
22
|
+
// The search counts its own work and evidence bytes (leaving room for the envelope), so
|
|
23
|
+
// running out ends the list early instead of failing the answer.
|
|
24
|
+
const search = new QueryWork(Math.max(1, work.remaining() - 25), Math.floor(maxBytes * 0.8));
|
|
25
|
+
try {
|
|
26
|
+
candidateSearch: for (const source of view.sources) {
|
|
27
|
+
// A binding enters an agent's focus only via deployment -> agent <- task -> grant -> resource
|
|
28
|
+
// <- binding. Walking that chain backwards from the matching bindings yields exactly the
|
|
29
|
+
// agents that can have evidence; the full per-agent check below still runs unchanged.
|
|
30
|
+
const projection = source.current;
|
|
31
|
+
const matches = projection.nodes.filter(node => {
|
|
32
|
+
search.step();
|
|
33
|
+
return (node.label === 'GuardResourceBinding' &&
|
|
34
|
+
strings(node, 'actions').includes(args.action) &&
|
|
35
|
+
(string(node, 'resource_ref') === args.resourceRef ||
|
|
36
|
+
connected(projection, new Set([node.id]), 'G_MAPS_TO', search).some(external => string(external, 'arn') === args.resourceRef)));
|
|
37
|
+
});
|
|
38
|
+
if (!matches.length)
|
|
39
|
+
continue;
|
|
40
|
+
const resources = nodeIds(connected(projection, nodeIds(matches), 'G_BINDS_SELECTOR', search));
|
|
41
|
+
const grants = nodeIds(connected(projection, resources, 'G_TARGETS', search, true));
|
|
42
|
+
const tasks = nodeIds(connected(projection, grants, 'G_HAS_GRANT', search, true));
|
|
43
|
+
const deploymentIds = nodeIds(connected(projection, tasks, 'G_ASSIGNED_TO', search).filter(node => node.label === 'GuardDeployment'));
|
|
44
|
+
const reachable = nodeIds(connected(projection, deploymentIds, 'G_INSTANCE_OF', search));
|
|
45
|
+
for (const agent of projection.nodes.filter(node => node.label === 'GuardAgent' && reachable.has(node.id))) {
|
|
46
|
+
search.step();
|
|
47
|
+
const selected = focus(source.current, agent, search), selectedProjection = { ...source.current, ...selected };
|
|
48
|
+
const bindings = selected.nodes.filter(node => node.label === 'GuardResourceBinding' && strings(node, 'actions').includes(args.action));
|
|
49
|
+
const evidence = [];
|
|
50
|
+
for (const binding of bindings) {
|
|
51
|
+
search.step();
|
|
52
|
+
const external = connected(selectedProjection, new Set([binding.id]), 'G_MAPS_TO', search)[0];
|
|
53
|
+
if (string(binding, 'resource_ref') !== args.resourceRef &&
|
|
54
|
+
(!external || string(external, 'arn') !== args.resourceRef))
|
|
55
|
+
continue;
|
|
56
|
+
const toolName = string(binding, 'tool'), resource = string(binding, 'resource_ref');
|
|
57
|
+
if (!toolName || !resource)
|
|
58
|
+
continue;
|
|
59
|
+
for (const task of selected.nodes.filter(node => node.label === 'GuardTask')) {
|
|
60
|
+
search.step();
|
|
61
|
+
if (!connected(selectedProjection, new Set([task.id]), 'G_HAS_GRANT', search).some(grant => string(grant, 'tool') === toolName &&
|
|
62
|
+
strings(grant, 'resources').some(item => item === '*' || item === resource)))
|
|
63
|
+
continue;
|
|
64
|
+
if (candidates.length >= limit) {
|
|
65
|
+
truncated = true;
|
|
66
|
+
break candidateSearch;
|
|
67
|
+
}
|
|
68
|
+
const deployment = connected(selectedProjection, new Set([task.id]), 'G_ASSIGNED_TO', search);
|
|
69
|
+
const executions = connected(selectedProjection, nodeIds(deployment), 'G_EXECUTES_FOR', search, true).filter(node => string(node, 'tool') === toolName);
|
|
70
|
+
const policy = selectedPolicy(source.current, search, {
|
|
71
|
+
task,
|
|
72
|
+
executions,
|
|
73
|
+
resources: [binding],
|
|
74
|
+
action: args.action,
|
|
75
|
+
});
|
|
76
|
+
evidence.push(search.evidence({
|
|
77
|
+
taskId: string(task, 'task_id'),
|
|
78
|
+
tool: toolName,
|
|
79
|
+
resourceRef: resource,
|
|
80
|
+
action: args.action,
|
|
81
|
+
scope: string(binding, 'scope'),
|
|
82
|
+
externalResource: external ? safeProperties(external) : null,
|
|
83
|
+
executionBindings: executions.map(node => ({
|
|
84
|
+
id: node.entityId,
|
|
85
|
+
...safeProperties(node),
|
|
86
|
+
identities: connected(selectedProjection, new Set([node.id]), 'G_USES_IDENTITY', search).map(safeProperties),
|
|
87
|
+
})),
|
|
88
|
+
policyEvidence: jsonList(policy),
|
|
89
|
+
policyCoverage: policyCoverageOf(source.current, policy),
|
|
90
|
+
explanation: explain(source.current, scope, task, toolName, resource, now, search, {
|
|
91
|
+
action: args.action,
|
|
92
|
+
resourceBindingId: binding.entityId,
|
|
93
|
+
}),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (evidence.length)
|
|
98
|
+
candidates.push({
|
|
99
|
+
agent: agentSummary(source, source.current, agent, search),
|
|
100
|
+
evidence,
|
|
101
|
+
assessment: 'unknown',
|
|
102
|
+
limitations: [
|
|
103
|
+
'Potential access candidates; no effective permission decision.',
|
|
104
|
+
'Cloud containment is not exact object authorization.',
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// Out of work or evidence room: the candidates completed so far stand and the result
|
|
112
|
+
// says it is incomplete. With none completed there is nothing honest to return.
|
|
113
|
+
if (!(error instanceof MapQueryError) || error.code !== 'MAP_LIMIT_EXCEEDED' || !candidates.length)
|
|
114
|
+
throw error;
|
|
115
|
+
truncated = true;
|
|
116
|
+
truncatedReason = 'work_limit';
|
|
117
|
+
}
|
|
118
|
+
return { data: { candidates, truncated, ...(truncatedReason ? { truncatedReason } : {}) } };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { safeProperties } from '../../projection-fields.js';
|
|
2
|
+
import { sourceSelection } from '../context.js';
|
|
3
|
+
import { detailFlags } from '../flags.js';
|
|
4
|
+
import { jsonList } from '../json.js';
|
|
5
|
+
import { directAccess, identityScope, targetVisibility } from '../outside-guard.js';
|
|
6
|
+
import { policyCoverage, policyCoverageOf, selectedPolicy } from '../policy.js';
|
|
7
|
+
import { agentSummary, deployments, findAgent, findSource, focus, grantSummary, receipt, taskSummary, } from '../summaries.js';
|
|
8
|
+
import { string } from '../work.js';
|
|
9
|
+
/** One agent's neighborhood: evidence lists, boundary flags, outside-guard access and a graph. */
|
|
10
|
+
export const getAgentAccess = {
|
|
11
|
+
// Boundary evidence (ancestors, `*` bindings, identity scope) is read only here, where it is
|
|
12
|
+
// shown. Past the pair limit the policy evidence reads unknown instead of failing the answer.
|
|
13
|
+
request: args => ({
|
|
14
|
+
...sourceSelection(args),
|
|
15
|
+
agentId: args.agentId,
|
|
16
|
+
policy: { optional: true },
|
|
17
|
+
boundary: true,
|
|
18
|
+
}),
|
|
19
|
+
run({ scope, args, work, maxBytes }, view) {
|
|
20
|
+
const source = findSource(view, args.sourceId), projection = source.current;
|
|
21
|
+
const agent = findAgent(projection, args.agentId);
|
|
22
|
+
const focused = focus(projection, agent, work);
|
|
23
|
+
// The response graph carries node properties too: withhold out-of-scope targets there.
|
|
24
|
+
const visibleTarget = targetVisibility(projection, scope, work);
|
|
25
|
+
// Allowlisted properties only, also for a host-provided store.
|
|
26
|
+
const graph = {
|
|
27
|
+
...focused,
|
|
28
|
+
nodes: focused.nodes.map(node => {
|
|
29
|
+
const target = node.label === 'GuardDirectAccess' ? string(node, 'target_ref') : null;
|
|
30
|
+
const allowed = safeProperties(node);
|
|
31
|
+
if (target === null || visibleTarget(target))
|
|
32
|
+
return { ...node, properties: allowed };
|
|
33
|
+
const { target_ref: _withheld, ...properties } = allowed;
|
|
34
|
+
return { ...node, properties };
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
const policy = selectedPolicy(projection, work, { nodes: graph.nodes });
|
|
38
|
+
return {
|
|
39
|
+
graph,
|
|
40
|
+
data: {
|
|
41
|
+
agent: agentSummary(source, projection, agent, work),
|
|
42
|
+
deployments: graph.nodes
|
|
43
|
+
.filter(node => node.label === 'GuardDeployment')
|
|
44
|
+
.map(node => ({
|
|
45
|
+
id: node.entityId,
|
|
46
|
+
deploymentId: string(node, 'deployment_id'),
|
|
47
|
+
environment: string(node, 'environment'),
|
|
48
|
+
})),
|
|
49
|
+
tasks: graph.nodes.filter(node => node.label === 'GuardTask').map(taskSummary),
|
|
50
|
+
grants: graph.nodes.filter(node => node.label === 'GuardGrant').map(grantSummary),
|
|
51
|
+
connections: graph.nodes
|
|
52
|
+
.filter(node => node.label === 'GuardMcpConnection')
|
|
53
|
+
.map(node => ({ id: node.entityId, ...safeProperties(node) })),
|
|
54
|
+
bindings: graph.nodes
|
|
55
|
+
.filter(node => ['GuardExecutionBinding', 'GuardResourceBinding'].includes(node.label))
|
|
56
|
+
.map(node => ({ id: node.entityId, kind: node.label, ...safeProperties(node) })),
|
|
57
|
+
policyEvidence: jsonList(policy),
|
|
58
|
+
policyCoverage: policyCoverageOf(projection, policy),
|
|
59
|
+
outsideGuard: {
|
|
60
|
+
directAccess: directAccess(projection, graph, scope, work),
|
|
61
|
+
identityScope: identityScope(projection, graph),
|
|
62
|
+
},
|
|
63
|
+
boundaryFlags: [detailFlags(projection, agent, scope, work.remaining(), maxBytes)],
|
|
64
|
+
receipt: receipt(source),
|
|
65
|
+
assessment: 'unknown',
|
|
66
|
+
graphStatus: 'included',
|
|
67
|
+
limitations: [
|
|
68
|
+
'Declared and observed evidence only; current executor admission is required.',
|
|
69
|
+
'External object and prefix bindings retain their original scope.',
|
|
70
|
+
'Only S3 identity policies and host declarations are checked. Bucket policies, SCPs and permission boundaries are not, so identity-scope buckets are candidates and a deny elsewhere can remove the access. none_declared is the host claim, not a check, and no status means safe.',
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { sourceSelection } from '../context.js';
|
|
2
|
+
import { inventorySections } from '../inventory.js';
|
|
3
|
+
import { jsonList } from '../json.js';
|
|
4
|
+
import { page } from '../ordering.js';
|
|
5
|
+
import { policyCoverage, policyCoverageOf, selectedPolicy } from '../policy.js';
|
|
6
|
+
import { agentSummary, collection, findAgent, findSource, focus, receipt } from '../summaries.js';
|
|
7
|
+
import { string } from '../work.js';
|
|
8
|
+
/** One paged section of one agent's inventory, with the size of every section. */
|
|
9
|
+
export const getAgentInventory = {
|
|
10
|
+
// One agent can hold more task/binding/action pairs than one read evaluates: its policy
|
|
11
|
+
// evidence then reads unknown (`limit_exceeded`) and the rest of the answer stands.
|
|
12
|
+
request: args => ({ ...sourceSelection(args), agentId: args.agentId, policy: { optional: true } }),
|
|
13
|
+
run({ args, work }, view) {
|
|
14
|
+
const source = findSource(view, args.sourceId), agent = findAgent(source.current, args.agentId);
|
|
15
|
+
const selected = { ...source.current, ...focus(source.current, agent, work) };
|
|
16
|
+
const groups = inventorySections(source, selected, agent, work), section = (args.section ?? 'overview');
|
|
17
|
+
const policy = selectedPolicy(source.current, work, { nodes: selected.nodes });
|
|
18
|
+
groups.policies = [...jsonList(policy)];
|
|
19
|
+
// Policy evidence the store did not load has no size. Its count and the total of its
|
|
20
|
+
// section are null (as for any total that is not known), never zero: an empty list here
|
|
21
|
+
// is "not evaluated", not "none".
|
|
22
|
+
const policyNotLoaded = source.current.policyEvidenceNotLoaded === true;
|
|
23
|
+
return {
|
|
24
|
+
data: {
|
|
25
|
+
agent: agentSummary(source, selected, agent, work),
|
|
26
|
+
section,
|
|
27
|
+
...page(groups[section], args),
|
|
28
|
+
...(policyNotLoaded && section === 'policies' ? { total: null } : {}),
|
|
29
|
+
counts: Object.fromEntries(Object.entries(groups)
|
|
30
|
+
.filter(([key]) => key !== 'overview')
|
|
31
|
+
.map(([key, value]) => [key, policyNotLoaded && key === 'policies' ? null : value.length])),
|
|
32
|
+
policyEvidence: section === 'policies' ? page(groups.policies, args).items : [],
|
|
33
|
+
policyCoverage: policyCoverageOf(source.current, policy),
|
|
34
|
+
receipt: receipt(source),
|
|
35
|
+
assessment: 'unknown',
|
|
36
|
+
graphStatus: 'not_requested',
|
|
37
|
+
collection,
|
|
38
|
+
limitations: ['Read-only host observations and declared grants; current execution still requires admission.'],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { retainedChanges } from '../changes.js';
|
|
2
|
+
import { sourceSelection } from '../context.js';
|
|
3
|
+
import { findSource } from '../summaries.js';
|
|
4
|
+
import { string } from '../work.js';
|
|
5
|
+
/** What changed between a source's current snapshot and its previous retained one. */
|
|
6
|
+
export const getChanges = {
|
|
7
|
+
request: args => ({ ...sourceSelection(args), history: true }),
|
|
8
|
+
run: ({ scope, args, work }, view) => ({
|
|
9
|
+
data: retainedChanges(findSource(view, args.sourceId), args, scope, work),
|
|
10
|
+
}),
|
|
11
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fail } from '../../common.js';
|
|
2
|
+
import { MapQueryError } from '../../types.js';
|
|
3
|
+
import { sourceSelection } from '../context.js';
|
|
4
|
+
import { undeclaredCounts } from '../discovery.js';
|
|
5
|
+
import { policyCoverageOf, policyRecords } from '../policy.js';
|
|
6
|
+
import { freshness } from '../summaries.js';
|
|
7
|
+
import { string } from '../work.js';
|
|
8
|
+
/** Capture freshness and coverage per source, plus how much activity nobody declared. */
|
|
9
|
+
export const getInventoryHealth = {
|
|
10
|
+
// Health reports policy coverage for the whole source: past the pair limit it reads
|
|
11
|
+
// unknown instead of failing the health answer.
|
|
12
|
+
request: args => ({ ...sourceSelection(args), policy: { optional: true } }),
|
|
13
|
+
async run({ scope, args, now, work, staleSeconds, read }, view) {
|
|
14
|
+
// Activity nobody declared, per source: a second, optional read. If it fails, is too
|
|
15
|
+
// large or runs out of time, health still answers and the count reads unknown.
|
|
16
|
+
const activity = new Map();
|
|
17
|
+
let activityUnknown = 'not_reported';
|
|
18
|
+
if (scope.allTeams && view.sources.length) {
|
|
19
|
+
const budget = work.remaining() - 50;
|
|
20
|
+
try {
|
|
21
|
+
if (budget <= 0)
|
|
22
|
+
fail('MAP_TIMEOUT');
|
|
23
|
+
const found = await read({ ...sourceSelection(args), undeclared: true }, budget);
|
|
24
|
+
for (const source of found.sources)
|
|
25
|
+
activity.set(source.id, source);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const code = error instanceof MapQueryError ? error.code : 'MAP_UNAVAILABLE';
|
|
29
|
+
activityUnknown =
|
|
30
|
+
code === 'MAP_TIMEOUT'
|
|
31
|
+
? 'read_timeout'
|
|
32
|
+
: code === 'MAP_LIMIT_EXCEEDED'
|
|
33
|
+
? 'limit_exceeded'
|
|
34
|
+
: 'read_unavailable';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const undeclaredActivity = (source) => {
|
|
38
|
+
if (!scope.allTeams)
|
|
39
|
+
return { status: 'not_visible', reason: 'all_teams_required' };
|
|
40
|
+
const counted = activity.get(source.id);
|
|
41
|
+
if (!counted?.current.undeclared)
|
|
42
|
+
return { status: 'unknown', reason: activityUnknown };
|
|
43
|
+
// Counted against a newer publication than the one this response describes.
|
|
44
|
+
if (counted.current.id !== source.current.id)
|
|
45
|
+
return { status: 'unknown', reason: 'publication_changed' };
|
|
46
|
+
return undeclaredCounts(source, counted.current.undeclared);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
data: {
|
|
50
|
+
sources: view.sources.map(source => {
|
|
51
|
+
work.step();
|
|
52
|
+
return {
|
|
53
|
+
id: source.id,
|
|
54
|
+
sourceId: source.sourceId,
|
|
55
|
+
tenantId: source.tenantId,
|
|
56
|
+
profile: source.profile,
|
|
57
|
+
generatedAt: source.current.generatedAt,
|
|
58
|
+
snapshotId: source.current.snapshotId,
|
|
59
|
+
projectionId: source.current.id,
|
|
60
|
+
...freshness(source.current.generatedAt, now, staleSeconds),
|
|
61
|
+
coverage: {
|
|
62
|
+
...Object.fromEntries(Object.entries(source.current.coverage)
|
|
63
|
+
.filter(([component]) => component !== 'policy')
|
|
64
|
+
.map(([component, status]) => {
|
|
65
|
+
work.step();
|
|
66
|
+
return [component, { ...status, ...freshness(status.observedAt, now, staleSeconds) }];
|
|
67
|
+
})),
|
|
68
|
+
policy: policyCoverageOf(source.current, policyRecords(source.current, work)),
|
|
69
|
+
},
|
|
70
|
+
undeclaredActivity: undeclaredActivity(source),
|
|
71
|
+
};
|
|
72
|
+
}),
|
|
73
|
+
visibility: scope.allTeams ? 'enrolled_sources' : 'visible_agent_sources',
|
|
74
|
+
limitations: [
|
|
75
|
+
'Collection freshness is separate from current execution authority.',
|
|
76
|
+
'undeclaredActivity counts tasks and caller principals that ran through this executor and belong to no declared agent. It is shown to all-teams readers only, and says nothing about agents that do not use the guard.',
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|