@shieldaai/agent-map 0.1.0-alpha.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +652 -272
  2. package/dist/assets.js +16 -26
  3. package/dist/boundary-flags.d.ts +73 -0
  4. package/dist/boundary-flags.js +352 -0
  5. package/dist/boundary.d.ts +18 -3
  6. package/dist/boundary.js +71 -21
  7. package/dist/cli-entry.d.ts +11 -0
  8. package/dist/cli-entry.js +48 -0
  9. package/dist/cli.js +40 -43
  10. package/dist/common.d.ts +34 -0
  11. package/dist/common.js +94 -0
  12. package/dist/http.js +78 -51
  13. package/dist/identity-cli.js +93 -46
  14. package/dist/identity-http.js +226 -94
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/mcp.js +40 -5
  18. package/dist/oidc.d.ts +5 -0
  19. package/dist/oidc.js +121 -38
  20. package/dist/policy-reader.d.ts +56 -3
  21. package/dist/policy-reader.js +823 -110
  22. package/dist/projection-fields.d.ts +4 -0
  23. package/dist/projection-fields.js +144 -0
  24. package/dist/service/changes.d.ts +5 -0
  25. package/dist/service/changes.js +255 -0
  26. package/dist/service/context.d.ts +33 -0
  27. package/dist/service/context.js +2 -0
  28. package/dist/service/discovery.d.ts +6 -0
  29. package/dist/service/discovery.js +31 -0
  30. package/dist/service/explain.d.ts +12 -0
  31. package/dist/service/explain.js +126 -0
  32. package/dist/service/flags.d.ts +20 -0
  33. package/dist/service/flags.js +70 -0
  34. package/dist/service/inventory.d.ts +4 -0
  35. package/dist/service/inventory.js +62 -0
  36. package/dist/service/json.d.ts +8 -0
  37. package/dist/service/json.js +7 -0
  38. package/dist/service/operations/discovery.d.ts +5 -0
  39. package/dist/service/operations/discovery.js +90 -0
  40. package/dist/service/operations/explain-access.d.ts +3 -0
  41. package/dist/service/operations/explain-access.js +25 -0
  42. package/dist/service/operations/find-agents-with-access.d.ts +3 -0
  43. package/dist/service/operations/find-agents-with-access.js +120 -0
  44. package/dist/service/operations/get-agent-access.d.ts +3 -0
  45. package/dist/service/operations/get-agent-access.js +75 -0
  46. package/dist/service/operations/get-agent-inventory.d.ts +3 -0
  47. package/dist/service/operations/get-agent-inventory.js +42 -0
  48. package/dist/service/operations/get-changes.d.ts +3 -0
  49. package/dist/service/operations/get-changes.js +11 -0
  50. package/dist/service/operations/get-inventory-health.d.ts +3 -0
  51. package/dist/service/operations/get-inventory-health.js +81 -0
  52. package/dist/service/operations/get-resource-access.d.ts +3 -0
  53. package/dist/service/operations/get-resource-access.js +48 -0
  54. package/dist/service/operations/list-agents.d.ts +3 -0
  55. package/dist/service/operations/list-agents.js +92 -0
  56. package/dist/service/operations/list-owners-resources.d.ts +5 -0
  57. package/dist/service/operations/list-owners-resources.js +69 -0
  58. package/dist/service/ordering.d.ts +9 -0
  59. package/dist/service/ordering.js +47 -0
  60. package/dist/service/outside-guard.d.ts +68 -0
  61. package/dist/service/outside-guard.js +313 -0
  62. package/dist/service/policy.d.ts +26 -0
  63. package/dist/service/policy.js +299 -0
  64. package/dist/service/schemas.d.ts +16 -0
  65. package/dist/service/schemas.js +192 -0
  66. package/dist/service/summaries.d.ts +32 -0
  67. package/dist/service/summaries.js +247 -0
  68. package/dist/service/work.d.ts +33 -0
  69. package/dist/service/work.js +116 -0
  70. package/dist/service.d.ts +5 -5
  71. package/dist/service.js +110 -645
  72. package/dist/store/ancestors.d.ts +9 -0
  73. package/dist/store/ancestors.js +86 -0
  74. package/dist/store/discovery.d.ts +8 -0
  75. package/dist/store/discovery.js +288 -0
  76. package/dist/store/evidence.d.ts +12 -0
  77. package/dist/store/evidence.js +90 -0
  78. package/dist/store/identity-scope.d.ts +9 -0
  79. package/dist/store/identity-scope.js +71 -0
  80. package/dist/store/nodes.d.ts +24 -0
  81. package/dist/store/nodes.js +237 -0
  82. package/dist/store/projection.d.ts +19 -0
  83. package/dist/store/projection.js +242 -0
  84. package/dist/store/queries.d.ts +48 -0
  85. package/dist/store/queries.js +302 -0
  86. package/dist/store/read.d.ts +81 -0
  87. package/dist/store/read.js +135 -0
  88. package/dist/store/selection.d.ts +3 -0
  89. package/dist/store/selection.js +102 -0
  90. package/dist/store.d.ts +15 -0
  91. package/dist/store.js +102 -395
  92. package/dist/transport.d.ts +42 -2
  93. package/dist/transport.js +144 -16
  94. package/dist/types.d.ts +184 -4
  95. package/dist/types.js +52 -11
  96. package/package.json +16 -3
@@ -0,0 +1,237 @@
1
+ import neo4j from 'neo4j-driver';
2
+ import { immutableJson } from '@shieldaai/agent-guard/internal';
3
+ import { s3BucketName, shortenScopeList } from '../common.js';
4
+ import { MAP_PROPERTIES } from '../projection-fields.js';
5
+ import { MAP_LABELS, mapIdentifier, MapQueryError, } from '../types.js';
6
+ export const SCOPE_RECORD_BYTES = 1024 * 1024;
7
+ export const SCOPE_HEADROOM_BYTES = 64 * 1024;
8
+ /**
9
+ * A stable order for bounded, already materialized rows. Each row is serialized once: the
10
+ * comparator would otherwise re-serialize rows of up to 1 MiB on every comparison.
11
+ */
12
+ export function sortedBySerialization(rows) {
13
+ return rows
14
+ .map(row => [JSON.stringify(row), row])
15
+ .sort((a, b) => a[0].localeCompare(b[0], 'en-US'))
16
+ .map(entry => entry[1]);
17
+ }
18
+ /**
19
+ * Tools sharing a role each list their own extras. Past the budget (1 MiB, or what is left of
20
+ * the read's byte budget if that is less), keep every record but drop the lists and report
21
+ * them truncated, rather than fail the read.
22
+ */
23
+ export function boundedRecords(records, budget = SCOPE_RECORD_BYTES) {
24
+ if (Buffer.byteLength(JSON.stringify(records), 'utf8') <= budget)
25
+ return records;
26
+ return records.map(item => shortenScopeList(item, 0));
27
+ }
28
+ /** Stands in for a scope row that could not be kept; the classifier reads it as truncated. */
29
+ export const oversized = (key) => immutableJson({ key: typeof key === 'number' ? key : -1, oversized: true });
30
+ /** S3 bucket names that visible host bindings map to; the outside-guard reach asks only about these. */
31
+ export function boundBuckets(nodes) {
32
+ const names = new Set();
33
+ for (const node of nodes) {
34
+ if (node.label !== 'GuardExternalResource' || node.properties.provider !== 'aws')
35
+ continue;
36
+ const name = s3BucketName(node.properties.arn);
37
+ if (name !== null)
38
+ names.add(name);
39
+ }
40
+ return [...names].sort();
41
+ }
42
+ export function text(value) {
43
+ return mapIdentifier(value, 'MAP_UNAVAILABLE');
44
+ }
45
+ export function converted(value) {
46
+ if (neo4j.isInt(value)) {
47
+ if (!value.inSafeRange())
48
+ throw new MapQueryError('MAP_UNAVAILABLE');
49
+ return value.toNumber();
50
+ }
51
+ if (Array.isArray(value))
52
+ return value.map(converted);
53
+ if (value && typeof value === 'object')
54
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, converted(child)]));
55
+ return value;
56
+ }
57
+ export function safeNode(raw, label, sourceId, projectionId) {
58
+ if (!MAP_LABELS.includes(label) || !raw || typeof raw !== 'object' || Array.isArray(raw))
59
+ throw new MapQueryError('MAP_UNAVAILABLE');
60
+ const data = raw;
61
+ if (data.source_id !== sourceId || data.projection_id !== projectionId)
62
+ throw new MapQueryError('MAP_UNAVAILABLE');
63
+ const properties = {};
64
+ for (const field of MAP_PROPERTIES[label])
65
+ if (Object.hasOwn(data, field))
66
+ properties[field] = converted(data[field]);
67
+ const opaque = (value) => typeof value === 'string' && value.length === 71 && /^opaque:[a-f0-9]{64}$/.test(value);
68
+ const optionalOpaque = (value) => value == null || opaque(value);
69
+ const opaqueArray = (value, maximum) => Array.isArray(value) && value.length <= maximum && new Set(value).size === value.length && value.every(opaque);
70
+ if (label === 'GuardCredentialReference' &&
71
+ (!opaque(properties.credential_id) ||
72
+ !optionalOpaque(properties.issuer_ref) ||
73
+ !opaqueArray(properties.audience_refs, 128) ||
74
+ !opaqueArray(properties.recipient_refs, 128) ||
75
+ !['active', 'rotating', 'revoked', 'expired', 'unknown'].includes(properties.lifecycle_state) ||
76
+ !['current', 'due', 'overdue', 'unknown'].includes(properties.rotation_status)))
77
+ throw new MapQueryError('MAP_UNAVAILABLE');
78
+ if (label === 'GuardDeployment' &&
79
+ (!optionalOpaque(properties.runtime_ref) ||
80
+ !optionalOpaque(properties.build_ref) ||
81
+ ![undefined, null, 'found', 'none_declared'].includes(properties.direct_access_status)))
82
+ throw new MapQueryError('MAP_UNAVAILABLE');
83
+ // Outside-content labels feed the boundary flags; a malformed value must not read as declared.
84
+ const optionalFlag = (value) => value === undefined || value === null || typeof value === 'boolean';
85
+ const optionalMode = (value) => value === undefined || value === null || ['allow', 'require_approval', 'deny'].includes(value);
86
+ if (label === 'GuardTool' &&
87
+ (!optionalFlag(properties.reads_untrusted) ||
88
+ !optionalFlag(properties.sends_external) ||
89
+ !optionalMode(properties.untrusted_context) ||
90
+ !optionalMode(properties.untrusted_context_default)))
91
+ throw new MapQueryError('MAP_UNAVAILABLE');
92
+ if (label === 'GuardTask' &&
93
+ (!optionalFlag(properties.untrusted) || !optionalFlag(properties.delegated) || !optionalFlag(properties.derived)))
94
+ throw new MapQueryError('MAP_UNAVAILABLE');
95
+ if (label === 'GuardDirectAccess' &&
96
+ (!opaque(properties.access_id) ||
97
+ !optionalOpaque(properties.target_ref) ||
98
+ !['shell', 'database', 'network', 'sdk', 'mcp', 'other'].includes(properties.kind) ||
99
+ typeof properties.description !== 'string' ||
100
+ // The same display rule as the manifest: not blank (including separators) and no NUL.
101
+ /^[\s\u001c-\u001f\u0085\uFEFF]*$/.test(properties.description) ||
102
+ properties.description.includes('\0') ||
103
+ [...properties.description].length > 1024 ||
104
+ typeof properties.deployment_id !== 'string' ||
105
+ !properties.deployment_id ||
106
+ [...properties.deployment_id].length > 4096))
107
+ throw new MapQueryError('MAP_UNAVAILABLE');
108
+ // Fields the boundary flags read: a malformed value must not read as a known fact.
109
+ if (label === 'GuardGrant' &&
110
+ (typeof properties.tool !== 'string' ||
111
+ !Array.isArray(properties.resources) ||
112
+ properties.resources.some(item => typeof item !== 'string')))
113
+ throw new MapQueryError('MAP_UNAVAILABLE');
114
+ if (label === 'GuardTask' &&
115
+ (!optionalFlag(properties.revoked) ||
116
+ !(properties.deadline == null || typeof properties.deadline === 'number') ||
117
+ !(properties.allowed_recipients == null ||
118
+ (Array.isArray(properties.allowed_recipients) &&
119
+ properties.allowed_recipients.every(item => typeof item === 'string')))))
120
+ throw new MapQueryError('MAP_UNAVAILABLE');
121
+ if (label === 'GuardResourceBinding' &&
122
+ properties.sensitivity != null &&
123
+ !['public', 'internal', 'confidential', 'restricted', 'unknown'].includes(properties.sensitivity))
124
+ throw new MapQueryError('MAP_UNAVAILABLE');
125
+ if (label === 'GuardResourceBinding' &&
126
+ properties.approved_destinations != null &&
127
+ !opaqueArray(properties.approved_destinations, 128))
128
+ throw new MapQueryError('MAP_UNAVAILABLE');
129
+ if (label === 'GuardExternalResource' && properties.provider === 'host') {
130
+ if (!opaque(properties.host_resource_id) ||
131
+ !['service', 'repository', 'database', 'queue', 'storage', 'other'].includes(properties.kind))
132
+ throw new MapQueryError('MAP_UNAVAILABLE');
133
+ // A host resource cannot acquire AWS identity through extra graph properties.
134
+ for (const key of [
135
+ 'arn',
136
+ 'partition',
137
+ 'account_id',
138
+ 'region',
139
+ 'cloud_provider_label',
140
+ 'cloud_provider_id',
141
+ 'cloud_match_kind',
142
+ ])
143
+ delete properties[key];
144
+ }
145
+ return {
146
+ id: text(data.id),
147
+ entityId: text(data.entity_id),
148
+ sourceId,
149
+ projectionId,
150
+ label: label,
151
+ properties: immutableJson(properties, 4, 50_000, 512 * 1024),
152
+ };
153
+ }
154
+ export function observationTime(value) {
155
+ if (typeof value !== 'string' ||
156
+ !/^(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?Z$/.test(value))
157
+ return undefined;
158
+ const time = Date.parse(value);
159
+ return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? time : undefined;
160
+ }
161
+ export function coreCaptureTimes(raw) {
162
+ // Older or incomplete graph metadata has unknown capture age. Recomposition
163
+ // changes generated_at, so it is never a safe substitute for these timestamps.
164
+ if (raw === null || raw === undefined)
165
+ return {};
166
+ try {
167
+ if (typeof raw !== 'string' || Buffer.byteLength(raw, 'utf8') > 4096)
168
+ throw new Error();
169
+ const parsed = JSON.parse(raw);
170
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
171
+ throw new Error();
172
+ const result = {};
173
+ for (const component of ['tasks', 'tools']) {
174
+ const item = parsed[component];
175
+ if (!item || typeof item !== 'object' || Array.isArray(item))
176
+ throw new Error();
177
+ const record = item;
178
+ if (Object.keys(record).sort().join(',') !== 'observedAt,status' ||
179
+ record.status !== 'complete' ||
180
+ observationTime(record.observedAt) === undefined)
181
+ throw new Error();
182
+ result[component] = record.observedAt;
183
+ }
184
+ return result;
185
+ }
186
+ catch {
187
+ throw new MapQueryError('MAP_UNAVAILABLE');
188
+ }
189
+ }
190
+ export function coverage(nodes, captures, profile, summary, now) {
191
+ const connections = nodes.filter(node => node.label === 'GuardMcpConnection');
192
+ const observed = connections.filter(node => node.properties.capture_status === 'observed').length;
193
+ const references = nodes.filter(node => (node.label === 'GuardExternalResource' || node.label === 'GuardExternalIdentity') &&
194
+ node.properties.provider !== 'host');
195
+ const captured = references.filter(node => typeof node.properties.cloud_status === 'string');
196
+ const resolved = captured.filter(node => node.properties.cloud_status === 'resolved').length;
197
+ const at = (values) => {
198
+ const times = values
199
+ .map(value => (typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN))
200
+ .filter(value => Number.isFinite(value) && value >= 0 && value <= 253_402_300_799_999);
201
+ return times.length ? { observedAt: new Date(Math.min(...times)).toISOString() } : {};
202
+ };
203
+ // Aggregate only visible connections. A new composition cannot renew a cached
204
+ // observation, and one missing/invalid/future timestamp must not disappear
205
+ // behind another fresh connection. Unavailable records have only capture time.
206
+ const mcpTimes = connections
207
+ .flatMap(node => node.properties.capture_status === 'observed'
208
+ ? [node.properties.capture_observed_at, node.properties.observed_at]
209
+ : [node.properties.capture_observed_at])
210
+ .map(observationTime);
211
+ const mcpCapture = mcpTimes.length && mcpTimes.every(time => time !== undefined && time <= now + 60_000)
212
+ ? { observedAt: new Date(Math.min(...mcpTimes)).toISOString() }
213
+ : {};
214
+ return {
215
+ tasks: summary
216
+ ? { status: 'not_collected' }
217
+ : { status: 'complete', ...(captures.tasks ? { observedAt: captures.tasks } : {}) },
218
+ tools: summary
219
+ ? { status: 'not_collected' }
220
+ : { status: 'complete', ...(captures.tools ? { observedAt: captures.tools } : {}) },
221
+ manifest: profile === 'core'
222
+ ? { status: 'not_collected' }
223
+ : { status: summary ? 'partial' : 'complete', ...at(nodes.map(node => node.properties.manifest_observed_at)) },
224
+ mcp: summary || profile !== 'host-mcp'
225
+ ? { status: 'not_collected' }
226
+ : {
227
+ status: observed === connections.length ? 'complete' : observed ? 'partial' : 'unavailable',
228
+ ...mcpCapture,
229
+ },
230
+ cloud: !captured.length
231
+ ? { status: 'not_collected' }
232
+ : {
233
+ status: resolved === references.length ? 'complete' : resolved ? 'partial' : 'unavailable',
234
+ ...at(captured.map(node => node.properties.cloud_observed_at)),
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,19 @@
1
+ import { type MapNode, type MapProjection, type MapSource } from '../types.js';
2
+ import { StoreRead } from './read.js';
3
+ /** One source row, as the source query returned it. */
4
+ export interface SourceRow {
5
+ readonly sourceId: string;
6
+ readonly tenantId: string;
7
+ readonly currentId: string;
8
+ readonly profile: MapSource['profile'];
9
+ /** The snapshot's enrolled cloud coverage (its policy age limit), unparsed; read only when evidence is judged. */
10
+ readonly enrolled: () => unknown;
11
+ }
12
+ /**
13
+ * One projection of one source: its nodes label by label (each query limited to what earlier
14
+ * ones authorized), the edges among them and, for the current projection, the evidence the
15
+ * read mode asks for. Ancestors and identity scope are queued and read after every source.
16
+ */
17
+ export declare function readProjection(read: StoreRead, source: SourceRow, id: string, snapshotId: string, generatedAt: string, rawCoverage: unknown, currentNodes?: readonly MapNode[]): Promise<MapProjection>;
18
+ /** Every selected source with its current projection and, for a history read, its previous one. */
19
+ export declare function readSources(read: StoreRead): Promise<MapSource[]>;
@@ -0,0 +1,242 @@
1
+ import neo4j from 'neo4j-driver';
2
+ import { s3BucketName } from '../common.js';
3
+ import { identityScopePlan, policyEvidence, policyPlan } from '../policy-reader.js';
4
+ import { MAP_LABELS, MAP_RELATIONSHIPS, mapIdentifier, MapQueryError, } from '../types.js';
5
+ import { readPolicyRows } from './evidence.js';
6
+ import { boundBuckets, converted, coreCaptureTimes, coverage, safeNode, SCOPE_RECORD_BYTES, text } from './nodes.js';
7
+ import { EDGES, HIDDEN_SCOPE, IN_SCOPE_TARGETS, NODES, OWNER_INDEX, OWNER_INDEX_SCOPED, PREVIOUS, READ_ORDER, WILDCARD, } from './queries.js';
8
+ import { StoreRead } from './read.js';
9
+ /**
10
+ * One projection of one source: its nodes label by label (each query limited to what earlier
11
+ * ones authorized), the edges among them and, for the current projection, the evidence the
12
+ * read mode asks for. Ancestors and identity scope are queued and read after every source.
13
+ */
14
+ export async function readProjection(read, source, id, snapshotId, generatedAt, rawCoverage, currentNodes = []) {
15
+ const { scope, selection, agentSelected, boundary, accessMode } = read;
16
+ const { sourceId, tenantId, currentId, profile } = source;
17
+ const captures = coreCaptureTimes(rawCoverage);
18
+ const params = {
19
+ ...read.common,
20
+ sourceId,
21
+ tenantId,
22
+ projectionId: id,
23
+ currentId,
24
+ labels: [...MAP_LABELS],
25
+ currentAgentEntityIds: currentNodes.filter(node => node.label === 'GuardAgent').map(node => node.entityId),
26
+ currentTaskEntityIds: currentNodes.filter(node => node.label === 'GuardTask').map(node => node.entityId),
27
+ };
28
+ const nodes = [];
29
+ // Ids already loaded, by label: each label's query is limited to what earlier ones found.
30
+ const idsByLabel = new Map();
31
+ const nodeQueries = NODES[accessMode][id === currentId ? 0 : 1];
32
+ for (const label of selection.summary
33
+ ? ['GuardAgent', 'GuardDeployment', 'GuardOwner']
34
+ : selection.owners
35
+ ? scope.allTeams
36
+ ? OWNER_INDEX
37
+ : OWNER_INDEX_SCOPED
38
+ : READ_ORDER) {
39
+ const authorized = Object.fromEntries(MAP_LABELS.map(kind => [`${kind}Ids`, idsByLabel.get(kind) ?? []]));
40
+ await read.run(nodeQueries[label], { ...params, ...authorized, nodeLimit: neo4j.int(read.limits.nodes - read.nodeCount + 1) }, item => {
41
+ if (++read.nodeCount > read.limits.nodes)
42
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
43
+ if (item.get('label') !== label)
44
+ throw new MapQueryError('MAP_UNAVAILABLE');
45
+ const node = safeNode(item.get('node'), label, sourceId, id);
46
+ read.charge(node);
47
+ nodes.push(node);
48
+ const ids = idsByLabel.get(label);
49
+ if (ids)
50
+ ids.push(node.id);
51
+ else
52
+ idsByLabel.set(label, [node.id]);
53
+ });
54
+ }
55
+ // Delegation ancestors are read after every source's own nodes and edges (below).
56
+ if (agentSelected && boundary && id === currentId)
57
+ read.ancestorJobs.push({ sourceId, params });
58
+ const nodeIds = nodes.map(node => node.id);
59
+ if (new Set(nodeIds).size !== nodes.length || new Set(nodes.map(node => node.entityId)).size !== nodes.length)
60
+ throw new MapQueryError('MAP_UNAVAILABLE');
61
+ const ids = new Set(nodeIds);
62
+ const edges = [];
63
+ await read.run(EDGES, {
64
+ ...params,
65
+ nodeIds,
66
+ relationships: [...MAP_RELATIONSHIPS],
67
+ edgeLimit: neo4j.int(read.limits.edges - read.edgeCount + 1),
68
+ }, item => {
69
+ if (++read.edgeCount > read.limits.edges)
70
+ throw new MapQueryError('MAP_LIMIT_EXCEEDED');
71
+ const source = text(item.get('source')), target = text(item.get('target')), kind = item.get('kind');
72
+ if (!ids.has(source) || !ids.has(target) || !MAP_RELATIONSHIPS.includes(kind))
73
+ throw new MapQueryError('MAP_UNAVAILABLE');
74
+ const edge = { source, target, kind: kind };
75
+ read.charge(edge);
76
+ edges.push(edge);
77
+ });
78
+ let projection = {
79
+ id,
80
+ snapshotId,
81
+ generatedAt,
82
+ status: 'published',
83
+ nodes,
84
+ edges,
85
+ coverage: coverage(nodes, captures, profile, (selection.summary ?? false) || selection.owners === true, read.now() * 1000),
86
+ };
87
+ if (agentSelected && boundary && id === currentId) {
88
+ // The agent's own `*` grants (ancestors' grants are not read).
89
+ const wildcardTools = [
90
+ ...new Set(nodes
91
+ .filter(node => node.label === 'GuardGrant')
92
+ .filter(node => node.properties.resources.includes('*'))
93
+ .map(node => node.properties.tool)),
94
+ ].sort();
95
+ // No prototype: a tool may be named like an Object.prototype member.
96
+ const wildcardSensitivity = Object.create(null);
97
+ if (wildcardTools.length)
98
+ await read.run(WILDCARD[accessMode], { ...params, wildcardTools }, item => {
99
+ const tool = item.get('tool'), bindings = converted(item.get('bindings'));
100
+ if (typeof tool !== 'string' ||
101
+ !wildcardTools.includes(tool) ||
102
+ !Array.isArray(bindings) ||
103
+ bindings.length > 5 ||
104
+ Object.hasOwn(wildcardSensitivity, tool) ||
105
+ bindings.some(binding => !binding ||
106
+ typeof binding.id !== 'string' ||
107
+ !['confidential', 'restricted'].includes(binding.sensitivity)))
108
+ throw new MapQueryError('MAP_UNAVAILABLE');
109
+ // The same identifier rule as every node id, so list and detail agree.
110
+ for (const binding of bindings)
111
+ mapIdentifier(binding.id, 'MAP_UNAVAILABLE');
112
+ wildcardSensitivity[tool] = bindings.map(binding => ({
113
+ id: binding.id,
114
+ sensitivity: binding.sensitivity,
115
+ }));
116
+ });
117
+ read.charge(wildcardSensitivity);
118
+ projection = { ...projection, wildcardSensitivity };
119
+ }
120
+ const reach = selection.agentId === undefined && selection.outsideGuard === true;
121
+ // Policy evidence only where the caller shows it. A listing that asked for none
122
+ // (`policy: false`) is not bound by the pair limit; it still gets the reach and
123
+ // boundary reads below, which share this block's recheck.
124
+ const wantsPolicy = selection.policy !== false;
125
+ const evidenceRead = !selection.summary && !selection.history && !selection.flags && !selection.owners && id === currentId;
126
+ if (evidenceRead && (wantsPolicy || reach || (selection.agentId !== undefined && boundary))) {
127
+ let plan = { records: [], probes: [] };
128
+ let policyNotLoaded = false;
129
+ if (wantsPolicy) {
130
+ const select = selection.policy || {};
131
+ try {
132
+ plan = policyPlan(scope.companyId, tenantId, nodes, edges, 512 - read.policyPairCount, select);
133
+ }
134
+ catch (error) {
135
+ // An optional read answers without policy evidence and says so.
136
+ if (select.optional !== true || !(error instanceof MapQueryError) || error.code !== 'MAP_LIMIT_EXCEEDED')
137
+ throw error;
138
+ policyNotLoaded = true;
139
+ }
140
+ }
141
+ read.policyPairCount += plan.records.length;
142
+ const enrolled = source.enrolled();
143
+ const maximumAge = plan.probes.length ? read.maximumAgeFor(enrolled) : read.limits.policyAge;
144
+ const rows = await readPolicyRows(read, plan, params, true);
145
+ const evidence = policyEvidence(plan, rows, read.now(), maximumAge);
146
+ for (const item of evidence)
147
+ read.charge(item);
148
+ // A team reader sees only buckets and host targets that an in-scope binding maps;
149
+ // null allows all.
150
+ let readable = null;
151
+ let inScopeTargets;
152
+ if (selection.agentId !== undefined && boundary && !scope.allTeams) {
153
+ const names = new Set(boundBuckets(nodes));
154
+ const hosts = new Set();
155
+ let count = 0;
156
+ // Host ids only for targets the agent's own declared paths name: bounded by the read.
157
+ const targetRefs = [
158
+ ...new Set(nodes
159
+ .filter(node => node.label === 'GuardDirectAccess')
160
+ .map(node => node.properties.target_ref)
161
+ .filter((ref) => typeof ref === 'string')),
162
+ ];
163
+ await read.run(IN_SCOPE_TARGETS, { ...params, targetRefs }, item => {
164
+ if (++count > 20_000)
165
+ return;
166
+ const name = s3BucketName(item.get('arn'));
167
+ if (name !== null)
168
+ names.add(name);
169
+ const host = item.get('host');
170
+ if (typeof host === 'string' && /^opaque:[a-f0-9]{64}$/.test(host))
171
+ hosts.add(host);
172
+ });
173
+ // Past the bound, fall back to what this read holds: that only withholds more.
174
+ readable = count > 20_000 ? new Set(boundBuckets(nodes)) : names;
175
+ inScopeTargets = count > 20_000 ? [] : [...hosts].sort();
176
+ read.charge(inScopeTargets);
177
+ }
178
+ let outsideVisibleScope;
179
+ if (reach && !scope.allTeams) {
180
+ const visibleIds = nodes
181
+ .filter(node => node.label === 'GuardDeployment' || node.label === 'GuardTask')
182
+ .map(node => node.id);
183
+ const [hidden] = await read.run(HIDDEN_SCOPE, { ...params, visibleIds });
184
+ // Only an explicit "nothing hidden" lets none_found stand.
185
+ outsideVisibleScope = hidden?.get('hidden') !== false;
186
+ }
187
+ const enriched = {
188
+ ...projection,
189
+ ...(!wantsPolicy ? {} : policyNotLoaded ? { policyEvidenceNotLoaded: true } : { policyEvidence: evidence }),
190
+ ...(outsideVisibleScope === undefined ? {} : { outsideVisibleScope }),
191
+ ...(inScopeTargets === undefined ? {} : { inScopeTargets }),
192
+ };
193
+ const check = {
194
+ plan,
195
+ scopePlan: identityScopePlan(scope.companyId, tenantId, [], [], 0),
196
+ params,
197
+ rows,
198
+ scopeRows: [],
199
+ readable,
200
+ projection: enriched,
201
+ maximumAge,
202
+ scopeBudget: SCOPE_RECORD_BYTES,
203
+ };
204
+ read.policyChecks.push(check);
205
+ if ((selection.agentId !== undefined && boundary) || reach)
206
+ read.scopeJobs.push({ sourceId, check, reach, enrolled });
207
+ return enriched;
208
+ }
209
+ return projection;
210
+ }
211
+ /** Every selected source with its current projection and, for a history read, its previous one. */
212
+ export async function readSources(read) {
213
+ const { scope, selection } = read;
214
+ const sources = [];
215
+ for (const record of read.selected) {
216
+ const sourceId = text(record.get('id'));
217
+ const tenantId = text(record.get('tenantId'));
218
+ const currentId = text(record.get('currentId'));
219
+ const profile = record.get('profile');
220
+ if (!scope.tenantIds.includes(tenantId) || !['core', 'host', 'host-mcp'].includes(profile))
221
+ throw new MapQueryError('MAP_UNAVAILABLE');
222
+ const source = {
223
+ sourceId,
224
+ tenantId,
225
+ currentId,
226
+ profile,
227
+ enrolled: () => record.get('policyCoverage'),
228
+ };
229
+ const current = await readProjection(read, source, currentId, text(record.get('currentSnapshotId')), text(record.get('currentGeneratedAt')), record.get('currentCoverage'));
230
+ let previous = null;
231
+ if (selection.history && record.get('previousId')) {
232
+ const previousId = text(record.get('previousId'));
233
+ const previousRecords = await read.run(PREVIOUS, { sourceId, projectionId: previousId });
234
+ if (previousRecords.length === 1) {
235
+ const item = previousRecords[0];
236
+ previous = await readProjection(read, source, previousId, text(item.get('snapshotId')), text(item.get('generatedAt')), item.get('coverage'), current.nodes);
237
+ }
238
+ }
239
+ sources.push({ id: sourceId, tenantId, sourceId: text(record.get('nativeSourceId')), profile, current, previous });
240
+ }
241
+ return sources;
242
+ }
@@ -0,0 +1,48 @@
1
+ import { type GuardLabel } from '../types.js';
2
+ export interface QueryAccess {
3
+ readonly allTeams: boolean;
4
+ readonly agentSelected: boolean;
5
+ }
6
+ export declare const QUERY_ACCESS: readonly QueryAccess[];
7
+ export declare function agentPredicate(variable: string, access: QueryAccess): string;
8
+ export declare const SOURCES: string[][];
9
+ export declare const scoped: (variable: string) => string;
10
+ export declare const READ_ORDER: readonly GuardLabel[];
11
+ export declare const historicalVisibility: (label: 'GuardAgent' | 'GuardTask', parameter: string) => string;
12
+ export declare function through(path: string, label: GuardLabel): string;
13
+ export declare const predicates: Readonly<Record<Exclude<GuardLabel, 'GuardAgent'>, string>>;
14
+ export declare function nodePredicate(label: GuardLabel, access: QueryAccess, historical: boolean): string;
15
+ export declare const NODES: Readonly<Record<"GuardAgent" | "GuardCredentialReference" | "GuardDeployment" | "GuardDirectAccess" | "GuardExecutionBinding" | "GuardExternalIdentity" | "GuardExternalResource" | "GuardGrant" | "GuardMcpConnection" | "GuardMcpOperation" | "GuardOwner" | "GuardPrincipal" | "GuardResource" | "GuardResourceBinding" | "GuardTask" | "GuardTool", string>>[][];
16
+ export declare const checked: (variable: string) => string;
17
+ export declare const inScopeBinding: (binding: string) => string;
18
+ export declare const ancestorsQuery: (access: QueryAccess) => string;
19
+ export declare const ANCESTORS: string[];
20
+ export declare const ANCESTOR_TASKS: string;
21
+ export declare const wildcardQuery: (access: QueryAccess) => string;
22
+ export declare const WILDCARD: string[];
23
+ export declare const IN_SCOPE_TARGETS: string;
24
+ export declare const HIDDEN_SCOPE: string;
25
+ export declare const declaredTask: (task: string) => string;
26
+ export declare const declaredPrincipal: (principal: string) => string;
27
+ export declare const SNAPSHOT = "MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})";
28
+ export declare const UNDECLARED_COUNT_LIMIT = 10000;
29
+ export declare const UNDECLARED_COUNTS: string;
30
+ export declare const UNDECLARED_TASKS: string;
31
+ export declare const UNDECLARED_PRINCIPALS: string;
32
+ export declare const AWS_COLLECTION_LIMIT = 64;
33
+ export declare const AWS_COLLECTION_COLUMNS: readonly ['id', 'tenantId', 'partition', 'accountId', 'schemaVersion', 'status', 'capturedAt', 'contentHash'];
34
+ export declare const AWS_COLLECTIONS = "MATCH (c:GuardAwsCollection)\nWHERE c.company_id=$companyId AND c.tenant_id IN $tenantIds\nRETURN substring(c.id,0,65) AS id,substring(c.tenant_id,0,4097) AS tenantId,substring(c.partition,0,17) AS partition,\n substring(c.account_id,0,13) AS accountId,c.schema_version AS schemaVersion,substring(c.status,0,33) AS status,\n c.captured_at AS capturedAt,substring(c.content_hash,0,65) AS contentHash\nORDER BY id LIMIT $collectionLimit";
35
+ export declare const CLAIM_LIMIT = 10000;
36
+ export declare const CLAIMED_IDENTITIES: string;
37
+ export declare const BINDINGS_WITHOUT_IDENTITY: string;
38
+ export declare const AWS_MEMBER_LIMIT = 50000;
39
+ export declare const UNCLAIMED_COUNTS = "MATCH (c:GuardAwsCollection)-[m:G_AWS_OBSERVED]->(p:AWSPrincipal)\nWHERE c.id=$collectionId AND c.company_id=$companyId AND c.tenant_id=$collectionTenantId\nWITH c,m,p LIMIT $memberLimit\nRETURN count(p) AS observed,\n sum(CASE WHEN coalesce(p.arn IN $claimed,false) THEN 0 ELSE 1 END) AS unclaimed,\n sum(CASE WHEN m.capture_hash=c.content_hash AND m.captured_at=c.captured_at AND p.lastupdated=c.captured_at\n THEN 0 ELSE 1 END) AS inconsistent";
40
+ export declare const UNCLAIMED_PAGE = "MATCH (c:GuardAwsCollection)-[:G_AWS_OBSERVED]->(p:AWSPrincipal)\nWHERE c.id IN $collectionIds AND c.company_id=$companyId AND c.tenant_id IN $tenantIds\n AND NOT coalesce(p.arn IN $claimed,false)\nWITH c,p ORDER BY p.arn, c.id SKIP $pageOffset LIMIT $pageLimit\nRETURN substring(c.id,0,65) AS collectionId,substring(p.arn,0,2049) AS arn,p:AWSRole AS role,p:AWSUser AS user,\n substring(p.name,0,129) AS name,substring(p.path,0,513) AS path\nORDER BY arn, collectionId";
41
+ export declare const EDGES = "MATCH (n)-[:G_IN_SNAPSHOT]->(:GuardSnapshot {id:$projectionId})\nMATCH (n)-[r]->(m)\nWHERE n.id IN $nodeIds AND m.id IN $nodeIds AND type(r) IN $relationships\n AND n.source_id=$sourceId AND m.source_id=$sourceId\n AND n.projection_id=$projectionId AND m.projection_id=$projectionId\n AND n.tenant_id=$tenantId AND m.tenant_id=$tenantId\nRETURN n.id AS source,m.id AS target,type(r) AS kind ORDER BY source,target,kind LIMIT $edgeLimit";
42
+ export declare const PREVIOUS = "MATCH (p:GuardSnapshot {id:$projectionId,source_id:$sourceId,status:'published'})\nRETURN p.id AS id,p.snapshot_id AS snapshotId,p.generated_at AS generatedAt,\n substring(p.coverage_json,0,4097) AS coverage LIMIT 1";
43
+ export declare const MAX_STORE_NODES = 10000;
44
+ export declare const MAX_STORE_EDGES = 25000;
45
+ export declare const MAX_STORE_BYTES: number;
46
+ export declare const OWNER_INDEX: readonly GuardLabel[];
47
+ export declare const OWNER_INDEX_SCOPED: readonly GuardLabel[];
48
+ export declare const POLICY_SELECTION_KEYS: string[];