@shieldaai/agent-map 0.1.0-alpha.3 → 1.1.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
package/dist/assets.js CHANGED
@@ -1,39 +1,29 @@
1
1
  import { constants } from 'node:fs';
2
- import { open } from 'node:fs/promises';
3
2
  import { isAbsolute, join } from 'node:path';
4
- import { fail, trustedHostFile } from './boundary.js';
3
+ import { fail } from './common.js';
4
+ import { readHostFile } from './boundary.js';
5
+ /** Viewer assets run in authenticated browsers: this user's or root's regular files, never a link. */
6
+ const ASSET_FILE = { noFollow: true, owner: 'service-or-root', singleLink: false, private: false };
5
7
  /** Load the fixed public assets once; never follow a replaced leaf or read beyond the cap. */
6
8
  export async function loadMapAssets(directory) {
7
9
  const assets = new Map();
8
10
  if (directory === undefined)
9
11
  return assets;
10
- if (typeof directory !== 'string' || !isAbsolute(directory) ||
11
- typeof constants.O_NONBLOCK !== 'number' || typeof constants.O_NOFOLLOW !== 'number')
12
+ if (typeof directory !== 'string' ||
13
+ !isAbsolute(directory) ||
14
+ typeof constants.O_NONBLOCK !== 'number' ||
15
+ typeof constants.O_NOFOLLOW !== 'number')
12
16
  fail('MAP_CONFIG_INVALID');
13
17
  const maximum = 4 * 1024 * 1024;
14
- for (const [name, mime] of [['index.html', 'text/html; charset=utf-8'], ['app.js', 'text/javascript; charset=utf-8'], ['app.css', 'text/css; charset=utf-8']]) {
18
+ for (const [name, mime] of [
19
+ ['index.html', 'text/html; charset=utf-8'],
20
+ ['app.js', 'text/javascript; charset=utf-8'],
21
+ // The synthetic demo, which app.js imports only when a reader opens it.
22
+ ['demo.js', 'text/javascript; charset=utf-8'],
23
+ ['app.css', 'text/css; charset=utf-8'],
24
+ ]) {
15
25
  try {
16
- const file = await open(join(directory, name), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
17
- try {
18
- const info = await file.stat();
19
- if (!trustedHostFile(info) || info.size > maximum)
20
- fail('MAP_CONFIG_INVALID');
21
- const data = Buffer.alloc(maximum + 1);
22
- let bytes = 0;
23
- while (bytes < data.length) {
24
- const read = await file.read(data, bytes, data.length - bytes, null);
25
- if (!read.bytesRead)
26
- break;
27
- bytes += read.bytesRead;
28
- }
29
- if (bytes > maximum)
30
- fail('MAP_CONFIG_INVALID');
31
- // Do not retain the full maximum-sized allocation for a small asset.
32
- assets.set(`/${name}`, { body: Buffer.from(data.subarray(0, bytes)), mime });
33
- }
34
- finally {
35
- await file.close();
36
- }
26
+ assets.set(`/${name}`, { body: await readHostFile(join(directory, name), maximum, ASSET_FILE), mime });
37
27
  }
38
28
  catch {
39
29
  fail('MAP_CONFIG_INVALID');
@@ -0,0 +1,73 @@
1
+ import type { GuardRelation, MapNode } from './types.js';
2
+ /**
3
+ * "Dangerous three": a live task of an agent that can read private data, read outside
4
+ * content and send data out. Declared evidence only; a flag is never a permission decision,
5
+ * and missing or old data reads as `unknown`, never as safe.
6
+ */
7
+ export type Leg = 'present' | 'absent' | 'unknown';
8
+ export type DangerousThreeStatus = 'exposed' | 'mitigated' | 'unknown' | 'absent';
9
+ /** The read-only graph access the computation needs; the service supplies its bounded index. */
10
+ export interface FlagGraph {
11
+ linked(node: MapNode, kind: GuardRelation, reverse?: boolean): MapNode[];
12
+ /** In-scope bindings of a tool marked confidential or restricted (at most five), for `*` grants. */
13
+ sensitiveBindings(tool: string): readonly {
14
+ readonly id: string;
15
+ readonly sensitivity: string;
16
+ }[];
17
+ step(amount?: number): void;
18
+ }
19
+ export interface FlagEvidence {
20
+ readonly leg: 'privateData' | 'outsideContent' | 'externalSend' | 'mitigation';
21
+ readonly taskId: string;
22
+ readonly reason: string;
23
+ readonly tool?: string;
24
+ readonly resourceBindingId?: string;
25
+ }
26
+ export interface DangerousThree {
27
+ readonly kind: 'dangerous_three';
28
+ readonly status: DangerousThreeStatus;
29
+ /** The task that decides the status; legs and evidence both describe this task. */
30
+ readonly taskId: string | null;
31
+ readonly legs: {
32
+ readonly privateData: Leg;
33
+ readonly outsideContent: Leg;
34
+ readonly externalSend: Leg;
35
+ };
36
+ readonly evidence: readonly FlagEvidence[];
37
+ readonly evidenceTruncated: boolean;
38
+ readonly notes: readonly string[];
39
+ readonly liveTasks: number;
40
+ readonly evidenceKind: 'declared';
41
+ readonly assessment: 'unknown';
42
+ readonly limitations: readonly string[];
43
+ }
44
+ export declare const DANGEROUS_THREE_LIMITATIONS: readonly string[];
45
+ /** Follows one link kind from a task to the tasks it names (delegation parent, handoff source). */
46
+ export type TaskLinks = (node: MapNode, kind: 'G_DELEGATED_FROM' | 'G_DERIVED_FROM') => MapNode[];
47
+ export interface TaskLiveness {
48
+ readonly live: boolean;
49
+ /** A linked task exists but this read cannot see it. */
50
+ readonly hidden: boolean;
51
+ readonly depthExceeded: boolean;
52
+ /** An older import never recorded whether the task has a parent or a source. */
53
+ readonly unrecorded: boolean;
54
+ /** More than one parent or source: malformed, so the chain says nothing. */
55
+ readonly ambiguous: boolean;
56
+ /**
57
+ * A task on the chain lacks a boolean `revoked`, or one on the delegation chain lacks a
58
+ * numeric `deadline`: it is treated as live, but that was not established.
59
+ */
60
+ readonly unverified: boolean;
61
+ }
62
+ /**
63
+ * The executor refuses a task when it or any task it descends from (delegation parents and
64
+ * handoff sources, transitively) is revoked, and when it or a delegation ancestor has expired;
65
+ * a handoff source's deadline does not bind the task. Walk up to MAX_DEPTH hops, the same reach
66
+ * as the store's ancestor read, so the one-agent and team-wide reads stop at the same place. A
67
+ * link that leaves the read makes liveness incomplete; the caller treats that as unknown.
68
+ */
69
+ export declare function taskLiveness(links: TaskLinks, task: MapNode, observedAt: number, step: () => void): TaskLiveness;
70
+ /** One status per agent: the worst of its live tasks. Legs and evidence describe that task. */
71
+ export declare function dangerousThree(graph: FlagGraph, agent: MapNode, observedAt: number, allTeams: boolean,
72
+ /** The read had no room for delegation ancestors: missing parents are not a scope matter. */
73
+ ancestorsNotLoaded?: boolean): DangerousThree;
@@ -0,0 +1,352 @@
1
+ // An unknown task may be exposed, so it outranks a mitigated one: amber never hides grey.
2
+ const RANK = { absent: 0, mitigated: 1, unknown: 2, exposed: 3 };
3
+ const SENSITIVE = new Set(['confidential', 'restricted']);
4
+ const MITIGATING = new Set(['require_approval', 'deny']);
5
+ /** Evidence items kept per leg (and for mitigation), so no present leg loses all of its evidence. */
6
+ const MAX_EVIDENCE_PER_LEG = 8;
7
+ function cappedEvidence(evidence) {
8
+ const kept = new Map();
9
+ const items = evidence.filter(item => {
10
+ const count = (kept.get(item.leg) ?? 0) + 1;
11
+ kept.set(item.leg, count);
12
+ return count <= MAX_EVIDENCE_PER_LEG;
13
+ });
14
+ return { items, truncated: items.length < evidence.length };
15
+ }
16
+ const MAX_DEPTH = 16;
17
+ export const DANGEROUS_THREE_LIMITATIONS = Object.freeze([
18
+ 'Declared grants and labels; not proof that data can leave.',
19
+ 'Only sends that the resolver marks external are checked; a tool that does not declare sendsExternal is unknown.',
20
+ ]);
21
+ const text = (node, key) => typeof node.properties[key] === 'string' ? node.properties[key] : null;
22
+ const list = (node, key) => {
23
+ const value = node.properties[key];
24
+ return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : null;
25
+ };
26
+ /** present beats unknown beats absent; an empty set of contributions is absent. */
27
+ function combine(legs) {
28
+ return legs.includes('present') ? 'present' : legs.includes('unknown') ? 'unknown' : 'absent';
29
+ }
30
+ const LINEAGE = {
31
+ live: true,
32
+ recipients: null,
33
+ hidden: false,
34
+ depthExceeded: false,
35
+ unrecorded: false,
36
+ ambiguous: false,
37
+ unverified: false,
38
+ };
39
+ /**
40
+ * The executor refuses a task when it or any task it descends from (delegation parents and
41
+ * handoff sources, transitively) is revoked, and when it or a delegation ancestor has expired;
42
+ * a handoff source's deadline does not bind the task. Walk up to MAX_DEPTH hops, the same reach
43
+ * as the store's ancestor read, so the one-agent and team-wide reads stop at the same place. A
44
+ * link that leaves the read makes liveness incomplete; the caller treats that as unknown.
45
+ */
46
+ export function taskLiveness(links, task, observedAt, step) {
47
+ const result = {
48
+ live: true,
49
+ hidden: false,
50
+ depthExceeded: false,
51
+ unrecorded: false,
52
+ ambiguous: false,
53
+ unverified: false,
54
+ };
55
+ let dead = false;
56
+ // The task's own state needs no chain: malformed links cannot make it live again.
57
+ const own = task.properties.revoked === true ||
58
+ (typeof task.properties.deadline === 'number' && task.properties.deadline <= observedAt);
59
+ // Deadlines: the task and its delegation parents only.
60
+ for (let current = task, depth = 0;; depth++) {
61
+ step();
62
+ const deadline = current.properties.deadline;
63
+ if (typeof deadline !== 'number')
64
+ result.unverified = true;
65
+ else if (deadline <= observedAt)
66
+ dead = true;
67
+ const parents = links(current, 'G_DELEGATED_FROM').filter(item => item.label === 'GuardTask');
68
+ if (parents.length !== 1 || depth >= MAX_DEPTH)
69
+ break;
70
+ current = parents[0];
71
+ }
72
+ // Revocation, and whether the chain is complete: every task reachable through both links.
73
+ const seen = new Set([task.id]);
74
+ let frontier = [task];
75
+ for (let depth = 0; frontier.length; depth++) {
76
+ const next = [];
77
+ for (const node of frontier) {
78
+ step();
79
+ if (node.properties.revoked === true)
80
+ dead = true;
81
+ else if (node.properties.revoked !== false)
82
+ result.unverified = true;
83
+ for (const [kind, recorded] of [
84
+ ['G_DELEGATED_FROM', node.properties.delegated],
85
+ ['G_DERIVED_FROM', node.properties.derived],
86
+ ]) {
87
+ if (recorded !== true && recorded !== false)
88
+ result.unrecorded = true;
89
+ // Past MAX_DEPTH hops nothing new is read, in either read, so a recorded link is a cut
90
+ // unless it leads back to a task already read within reach.
91
+ if (depth >= MAX_DEPTH) {
92
+ const known = links(node, kind).filter(item => item.label === 'GuardTask');
93
+ if (recorded === true && !(known.length === 1 && seen.has(known[0].id)))
94
+ result.depthExceeded = true;
95
+ continue;
96
+ }
97
+ const linked = links(node, kind).filter(item => item.label === 'GuardTask');
98
+ // Two visible links of one kind are malformed. (A second one outside a team reader's
99
+ // read is not seen; the collector writes one parent and one source per task.)
100
+ if (linked.length > 1)
101
+ result.ambiguous = true;
102
+ if (!linked.length && recorded === true)
103
+ result.hidden = true;
104
+ for (const item of linked) {
105
+ if (seen.has(item.id))
106
+ continue;
107
+ seen.add(item.id);
108
+ next.push(item);
109
+ }
110
+ }
111
+ }
112
+ frontier = next;
113
+ }
114
+ // A revoked or expired task on a malformed chain proves nothing about this task: unknown.
115
+ return own || (dead && !result.ambiguous) ? { ...result, live: false } : result;
116
+ }
117
+ /**
118
+ * Liveness over delegation parents and handoff sources, and the effective recipient set: the
119
+ * core checks recipients against every delegation ancestor, so any gap leaves it unknown.
120
+ */
121
+ function lineage(graph, task, observedAt) {
122
+ const life = taskLiveness((node, kind) => graph.linked(node, kind), task, observedAt, () => graph.step());
123
+ if (!life.live)
124
+ return { ...LINEAGE, live: false };
125
+ const complete = !life.hidden && !life.unrecorded && !life.depthExceeded && !life.ambiguous;
126
+ let recipients = complete ? new Set(list(task, 'allowed_recipients') ?? []) : null;
127
+ if (list(task, 'allowed_recipients') === null)
128
+ recipients = null;
129
+ for (let current = task, depth = 0; recipients !== null && depth <= MAX_DEPTH; depth++) {
130
+ const parent = graph.linked(current, 'G_DELEGATED_FROM').find(node => node.label === 'GuardTask');
131
+ if (!parent)
132
+ break;
133
+ const allowed = list(parent, 'allowed_recipients');
134
+ recipients = allowed === null ? null : new Set([...recipients].filter(item => allowed.includes(item)));
135
+ current = parent;
136
+ }
137
+ return {
138
+ live: true,
139
+ recipients: recipients === null ? null : [...recipients].sort(),
140
+ hidden: life.hidden,
141
+ depthExceeded: life.depthExceeded,
142
+ unrecorded: life.unrecorded,
143
+ ambiguous: life.ambiguous,
144
+ // Without a snapshot time expiry cannot be judged at all.
145
+ unverified: life.unverified || Number.isNaN(observedAt),
146
+ };
147
+ }
148
+ function evaluateTask(graph, task, observedAt) {
149
+ const chain = lineage(graph, task, observedAt);
150
+ if (!chain.live)
151
+ return null;
152
+ const taskId = task.entityId;
153
+ const evidence = [];
154
+ const grants = graph.linked(task, 'G_HAS_GRANT').filter(node => node.label === 'GuardGrant');
155
+ const privateLegs = [], outsideLegs = [], sendLegs = [];
156
+ // Private data: the task is already sensitive, or a granted binding is marked sensitive.
157
+ const sensitiveTask = task.properties.sensitive === true;
158
+ // A missing or malformed `sensitive` may hide private data and a send block alike.
159
+ const sensitiveKnown = typeof task.properties.sensitive === 'boolean';
160
+ if (sensitiveTask) {
161
+ privateLegs.push('present');
162
+ evidence.push({ leg: 'privateData', taskId, reason: 'task_sensitive' });
163
+ }
164
+ else if (!sensitiveKnown)
165
+ privateLegs.push('unknown');
166
+ const binding = (node, tool) => {
167
+ const sensitivity = text(node, 'sensitivity');
168
+ if (sensitivity !== null && SENSITIVE.has(sensitivity)) {
169
+ privateLegs.push('present');
170
+ evidence.push({
171
+ leg: 'privateData',
172
+ taskId,
173
+ reason: `binding_${sensitivity}`,
174
+ tool,
175
+ resourceBindingId: node.entityId,
176
+ });
177
+ }
178
+ else
179
+ privateLegs.push(sensitivity === 'public' || sensitivity === 'internal' ? 'absent' : 'unknown');
180
+ };
181
+ const tools = new Map();
182
+ for (const grant of grants) {
183
+ const tool = text(grant, 'tool');
184
+ const resources = list(grant, 'resources');
185
+ if (tool === null || resources === null) {
186
+ // A malformed grant says nothing about any leg.
187
+ privateLegs.push('unknown');
188
+ outsideLegs.push('unknown');
189
+ sendLegs.push('unknown');
190
+ continue;
191
+ }
192
+ const toolNodes = graph
193
+ .linked(grant, 'G_FOR_TOOL')
194
+ .filter(node => node.label === 'GuardTool' && text(node, 'name') === tool);
195
+ if (!tools.has(tool) || toolNodes.length === 1)
196
+ tools.set(tool, toolNodes.length === 1 ? toolNodes[0] : null);
197
+ if (resources.includes('*')) {
198
+ // `*` covers every resource of the tool, including ones the host never described: present
199
+ // when an in-scope binding of the tool is sensitive, otherwise unknown.
200
+ const matches = graph.sensitiveBindings(tool);
201
+ graph.step(matches.length + 1);
202
+ for (const match of matches) {
203
+ privateLegs.push('present');
204
+ evidence.push({
205
+ leg: 'privateData',
206
+ taskId,
207
+ reason: `binding_${match.sensitivity}`,
208
+ tool,
209
+ resourceBindingId: match.id,
210
+ });
211
+ }
212
+ if (!matches.length)
213
+ privateLegs.push('unknown');
214
+ }
215
+ const reached = new Set();
216
+ for (const selector of graph.linked(grant, 'G_TARGETS').filter(node => node.label === 'GuardResource')) {
217
+ const selected = text(selector, 'resource_id');
218
+ if (selected === '*' || selected === null || !resources.includes(selected))
219
+ continue;
220
+ reached.add(selected);
221
+ const bindings = graph
222
+ .linked(selector, 'G_BINDS_SELECTOR', true)
223
+ .filter(node => node.label === 'GuardResourceBinding' && text(node, 'tool') === tool);
224
+ // A granted resource the host never described has unknown sensitivity.
225
+ if (!bindings.length)
226
+ privateLegs.push('unknown');
227
+ for (const node of bindings)
228
+ binding(node, tool);
229
+ }
230
+ // A granted resource with no selector in the read says nothing about its data.
231
+ if (resources.some(resource => resource !== '*' && !reached.has(resource)))
232
+ privateLegs.push('unknown');
233
+ }
234
+ // Outside content and sending, per granted tool. A resolver can mark any call untrusted, so
235
+ // only an explicit `readsUntrusted: false` rules a tool out; undeclared stays unknown.
236
+ if (task.properties.untrusted === true) {
237
+ outsideLegs.push('present');
238
+ evidence.push({ leg: 'outsideContent', taskId, reason: 'task_untrusted' });
239
+ }
240
+ // The core refuses every external send from a sensitive family, and from a task whose
241
+ // effective recipient set is empty. Both stop only sends a resolver marks external, which
242
+ // is what `sendsExternal: true` promises; an undeclared tool may send unmarked.
243
+ const noRecipients = chain.recipients !== null && chain.recipients.length === 0;
244
+ const senders = [];
245
+ for (const [tool, node] of tools) {
246
+ graph.step();
247
+ const registered = node !== null && node.properties.registered === true && node.properties.known_metadata !== false;
248
+ if (!registered) {
249
+ outsideLegs.push('unknown');
250
+ sendLegs.push('unknown');
251
+ senders.push({ tool, declared: false, mode: null });
252
+ continue;
253
+ }
254
+ const reads = node.properties.reads_untrusted;
255
+ if (reads === true) {
256
+ outsideLegs.push('present');
257
+ evidence.push({ leg: 'outsideContent', taskId, reason: 'tool_reads_untrusted', tool });
258
+ }
259
+ else
260
+ outsideLegs.push(reads === false ? 'absent' : 'unknown');
261
+ const mode = (text(node, 'untrusted_context') ?? text(node, 'untrusted_context_default'));
262
+ const sends = node.properties.sends_external;
263
+ if (sends === false)
264
+ sendLegs.push('absent');
265
+ else if (sends === true) {
266
+ const blocked = sensitiveTask ? 'sensitive_egress_denied' : noRecipients ? 'no_effective_recipients' : null;
267
+ if (blocked) {
268
+ sendLegs.push('absent');
269
+ evidence.push({ leg: 'externalSend', taskId, reason: blocked, tool });
270
+ continue;
271
+ }
272
+ sendLegs.push(chain.recipients === null || !sensitiveKnown ? 'unknown' : 'present');
273
+ senders.push({ tool, declared: true, mode });
274
+ evidence.push({ leg: 'externalSend', taskId, reason: 'tool_sends_external', tool });
275
+ }
276
+ else {
277
+ sendLegs.push('unknown');
278
+ senders.push({ tool, declared: false, mode });
279
+ }
280
+ }
281
+ const legs = {
282
+ privateData: combine(privateLegs),
283
+ outsideContent: combine(outsideLegs),
284
+ externalSend: combine(sendLegs),
285
+ };
286
+ const values = Object.values(legs);
287
+ let status;
288
+ if (values.includes('absent'))
289
+ status = 'absent';
290
+ else if (values.includes('unknown'))
291
+ status = 'unknown';
292
+ else if (senders.some(sender => sender.declared && sender.mode === 'allow'))
293
+ status = 'exposed';
294
+ else if (senders.every(sender => MITIGATING.has(sender.mode))) {
295
+ status = 'mitigated';
296
+ for (const sender of senders)
297
+ evidence.push({ leg: 'mitigation', taskId, reason: `untrusted_context_${sender.mode}`, tool: sender.tool });
298
+ }
299
+ else
300
+ status = 'unknown';
301
+ // A chain read only in part (an ancestor hidden, not loaded, unrecorded or too deep), or
302
+ // one whose revocation or expiry facts are missing, may hold a revoked or expired ancestor,
303
+ // so the task may not be live: never call it absent.
304
+ if (status === 'absent' &&
305
+ (chain.hidden || chain.unrecorded || chain.depthExceeded || chain.ambiguous || chain.unverified))
306
+ status = 'unknown';
307
+ return { taskId, status, legs, evidence, chain };
308
+ }
309
+ /** One status per agent: the worst of its live tasks. Legs and evidence describe that task. */
310
+ export function dangerousThree(graph, agent, observedAt, allTeams,
311
+ /** The read had no room for delegation ancestors: missing parents are not a scope matter. */
312
+ ancestorsNotLoaded = false) {
313
+ const deployments = graph.linked(agent, 'G_INSTANCE_OF', true).filter(node => node.label === 'GuardDeployment');
314
+ const tasks = deployments.flatMap(deployment => graph.linked(deployment, 'G_ASSIGNED_TO', true).filter(node => node.label === 'GuardTask'));
315
+ const results = tasks
316
+ .map(task => evaluateTask(graph, task, observedAt))
317
+ .filter((item) => item !== null)
318
+ // A stable choice among equally bad tasks, independent of read order.
319
+ .sort((a, b) => RANK[b.status] - RANK[a.status] || (a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0));
320
+ const notes = [];
321
+ if (!results.length)
322
+ notes.push('no_live_tasks');
323
+ if (results.some(item => item.chain.hidden)) {
324
+ if (ancestorsNotLoaded)
325
+ notes.push('delegation_ancestors_not_loaded');
326
+ else
327
+ notes.push(allTeams ? 'delegation_parent_missing' : 'ancestor_evidence_outside_visible_scope');
328
+ }
329
+ if (results.some(item => item.chain.ambiguous))
330
+ notes.push('delegation_evidence_ambiguous');
331
+ if (results.some(item => item.chain.unverified))
332
+ notes.push('task_liveness_unverified');
333
+ if (results.some(item => item.chain.unrecorded))
334
+ notes.push('delegation_not_recorded');
335
+ if (results.some(item => item.chain.depthExceeded))
336
+ notes.push('delegation_depth_exceeded');
337
+ const worst = results[0] ?? null;
338
+ const listed = cappedEvidence(worst?.evidence ?? []);
339
+ return {
340
+ kind: 'dangerous_three',
341
+ status: worst?.status ?? 'unknown',
342
+ taskId: worst?.taskId ?? null,
343
+ legs: worst?.legs ?? { privateData: 'unknown', outsideContent: 'unknown', externalSend: 'unknown' },
344
+ evidence: listed.items,
345
+ evidenceTruncated: listed.truncated,
346
+ notes,
347
+ liveTasks: results.length,
348
+ evidenceKind: 'declared',
349
+ assessment: 'unknown',
350
+ limitations: DANGEROUS_THREE_LIMITATIONS,
351
+ };
352
+ }
@@ -1,7 +1,22 @@
1
- import type { Stats } from 'node:fs';
2
- export declare function fail(code: string): never;
1
+ import { type Stats } from 'node:fs';
3
2
  export declare function trustedHostFile(info: Stats): boolean;
3
+ /** Which files a launcher accepts. The rules differ by what the file is for, and each caller states its own. */
4
+ export interface HostFileRule {
5
+ /** Refuse a symbolic link at the path itself. */
6
+ readonly noFollow: boolean;
7
+ /** `service`: owned by this user. `service-or-root`: owned by the effective user or by root. */
8
+ readonly owner: 'service' | 'service-or-root';
9
+ /** Refuse a file with more than one hard link. */
10
+ readonly singleLink: boolean;
11
+ /** Refuse any group or other access, as for a key or a file that names secrets. */
12
+ readonly private: boolean;
13
+ }
14
+ /**
15
+ * Reads at most `maximum` bytes of a regular file that is not writable by group or others.
16
+ * The checks and the read use one non-blocking descriptor, so they apply to the bytes that
17
+ * are used, and a file that grows after the check is refused rather than read without bound.
18
+ */
19
+ export declare function readHostFile(path: string, maximum: number, rule: HostFileRule): Promise<Buffer>;
4
20
  export declare function fields(value: unknown, allowed: readonly string[], required?: readonly string[]): Record<string, unknown>;
5
- export declare function integer(value: unknown, fallback: number, min: number, max: number): number;
6
21
  /** Bounded strict JSON, including duplicate decoded keys that JSON.parse would discard. */
7
22
  export declare function parseRequest(bytes: Uint8Array, maximum?: number): unknown;
package/dist/boundary.js CHANGED
@@ -1,19 +1,68 @@
1
- import { MapQueryError } from './types.js';
2
- export function fail(code) { throw new MapQueryError(code); }
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { fail } from './common.js';
3
4
  // Configuration selects credential destinations. Viewer assets run in authenticated browsers.
4
5
  // Check the descriptor after open so the test applies to the bytes that will be used.
5
6
  export function trustedHostFile(info) {
6
- return typeof process.geteuid === 'function' && info.isFile() &&
7
- (info.uid === 0 || info.uid === process.geteuid()) && (info.mode & 0o022) === 0;
7
+ return (typeof process.geteuid === 'function' &&
8
+ info.isFile() &&
9
+ (info.uid === 0 || info.uid === process.geteuid()) &&
10
+ (info.mode & 0o022) === 0);
11
+ }
12
+ /**
13
+ * Reads at most `maximum` bytes of a regular file that is not writable by group or others.
14
+ * The checks and the read use one non-blocking descriptor, so they apply to the bytes that
15
+ * are used, and a file that grows after the check is refused rather than read without bound.
16
+ */
17
+ export async function readHostFile(path, maximum, rule) {
18
+ if (typeof constants.O_NONBLOCK !== 'number' ||
19
+ (rule.noFollow && !constants.O_NOFOLLOW) ||
20
+ (rule.owner === 'service' && typeof process.getuid !== 'function'))
21
+ fail('MAP_CONFIG_INVALID');
22
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (rule.noFollow ? constants.O_NOFOLLOW : 0));
23
+ try {
24
+ const info = await file.stat();
25
+ const owned = rule.owner === 'service'
26
+ ? info.isFile() && info.uid === process.getuid() && (info.mode & 0o022) === 0
27
+ : trustedHostFile(info);
28
+ if (!owned ||
29
+ info.size > maximum ||
30
+ (rule.singleLink && info.nlink !== 1) ||
31
+ (rule.private && (info.mode & 0o077) !== 0))
32
+ fail('MAP_CONFIG_INVALID');
33
+ const data = Buffer.alloc(maximum + 1);
34
+ let bytes = 0;
35
+ while (bytes < data.length) {
36
+ const read = await file.read(data, bytes, data.length - bytes, null);
37
+ if (!read.bytesRead)
38
+ break;
39
+ bytes += read.bytesRead;
40
+ }
41
+ if (bytes > maximum)
42
+ fail('MAP_CONFIG_INVALID');
43
+ // Do not retain the full maximum-sized allocation for a small file.
44
+ return Buffer.from(data.subarray(0, bytes));
45
+ }
46
+ finally {
47
+ await file.close();
48
+ }
8
49
  }
9
50
  export function fields(value, allowed, required = []) {
10
- if (!value || typeof value !== 'object' || Array.isArray(value) ||
11
- ![null, Object.prototype].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length)
51
+ if (!value ||
52
+ typeof value !== 'object' ||
53
+ Array.isArray(value) ||
54
+ ![null, Object.prototype].includes(Object.getPrototypeOf(value)) ||
55
+ Object.getOwnPropertySymbols(value).length)
12
56
  fail('MAP_CONFIG_INVALID');
57
+ if (Object.getOwnPropertyNames(value).some(key => !allowed.includes(key)))
58
+ fail('MAP_CONFIG_INVALID');
59
+ // Copy by the allowed names, never by a name the input supplied.
13
60
  const result = Object.create(null);
14
- for (const key of Object.getOwnPropertyNames(value)) {
61
+ for (const key of allowed) {
15
62
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
16
- if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor))
63
+ if (!descriptor)
64
+ continue;
65
+ if (!descriptor.enumerable || !('value' in descriptor))
17
66
  fail('MAP_CONFIG_INVALID');
18
67
  result[key] = descriptor.value;
19
68
  }
@@ -21,12 +70,6 @@ export function fields(value, allowed, required = []) {
21
70
  fail('MAP_CONFIG_INVALID');
22
71
  return result;
23
72
  }
24
- export function integer(value, fallback, min, max) {
25
- const result = value === undefined ? fallback : value;
26
- if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < min || result > max)
27
- fail('MAP_CONFIG_INVALID');
28
- return result;
29
- }
30
73
  /** Bounded strict JSON, including duplicate decoded keys that JSON.parse would discard. */
31
74
  export function parseRequest(bytes, maximum = 65_536) {
32
75
  if (bytes.byteLength > maximum)
@@ -35,8 +78,10 @@ export function parseRequest(bytes, maximum = 65_536) {
35
78
  const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
36
79
  let offset = 0;
37
80
  let nodes = 0;
38
- const space = () => { while (/[\t\n\r ]/.test(text[offset] ?? '\0'))
39
- offset++; };
81
+ const space = () => {
82
+ while (/[\t\n\r ]/.test(text[offset] ?? '\0'))
83
+ offset++;
84
+ };
40
85
  const string = () => {
41
86
  const start = offset++;
42
87
  while (offset < text.length) {
@@ -58,26 +103,31 @@ export function parseRequest(bytes, maximum = 65_536) {
58
103
  if (char === '{') {
59
104
  offset++;
60
105
  space();
61
- const object = Object.create(null);
106
+ // Members are collected first and become own data properties of a null-prototype
107
+ // object in one step, so a name from the text is never used as a write target.
108
+ const members = [];
109
+ const names = new Set();
110
+ const object = () => Object.assign(Object.create(null), Object.fromEntries(members));
62
111
  if (text[offset] === '}') {
63
112
  offset++;
64
- return object;
113
+ return object();
65
114
  }
66
115
  while (true) {
67
116
  space();
68
117
  if (text[offset] !== '"')
69
118
  throw new Error();
70
119
  const key = string();
71
- if (Object.hasOwn(object, key))
120
+ if (names.has(key))
72
121
  throw new Error();
122
+ names.add(key);
73
123
  space();
74
124
  if (text[offset++] !== ':')
75
125
  throw new Error();
76
- object[key] = value(depth + 1);
126
+ members.push([key, value(depth + 1)]);
77
127
  space();
78
128
  if (text[offset] === '}') {
79
129
  offset++;
80
- return object;
130
+ return object();
81
131
  }
82
132
  if (text[offset++] !== ',')
83
133
  throw new Error();
@@ -1,2 +1,13 @@
1
+ /** Optional `neo4j` configuration keys passed to the store as its per-read limits. */
2
+ export declare const STORE_LIMITS: readonly ['maxNodes', 'maxEdges', 'maxBytes'];
3
+ export declare const PACKAGE_VERSION: string;
1
4
  /** npm's POSIX executable is a symlink. Node resolves it when loading the module. */
2
5
  export declare function isCliEntry(meta: ImportMeta): boolean;
6
+ /** The specific safe code for a startup failure, and whether the usage line helps. */
7
+ export declare function startupFailure(error: unknown): {
8
+ code: string;
9
+ hint: string;
10
+ usage: boolean;
11
+ };
12
+ /** Prints `MAP_START_FAILED <code>: <hint>` (and the usage for an argument error) and sets the exit code. */
13
+ export declare function reportStartupFailure(error: unknown, usage: string): void;