@anchrd/intel-api 0.7.0 → 0.10.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.
@@ -1,4 +1,4 @@
1
- import type { AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, ListAgentsInput, ListNodesInput, Node, NodeAgent, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
1
+ import type { AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, ListAgentsInput, ListNodesInput, Node, NodeAgent, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
2
2
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
3
3
  export interface Actor {
4
4
  id: string;
@@ -55,6 +55,26 @@ export interface NodeRepository {
55
55
  findIdempotentRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
56
56
  insertNode(input: NewNode): Promise<Node>;
57
57
  agentApplicationId(nodeId: string): Promise<string | null>;
58
+ /**
59
+ * The audit row for replacing the key of the Application an agent runs as (D29, #207).
60
+ *
61
+ * ⚠️ Its own write because it is the only mutation on an agent node that changes nothing IN the
62
+ * node — no version, no row, nothing a reader could diff afterwards. Swapping which credential an
63
+ * agent acts with is the most security-relevant thing anybody does to it, and without this it
64
+ * would be the one act on the tree that leaves no trace in Intel at all. Gate audits its side;
65
+ * Gate does not know which Intel node the principal belongs to.
66
+ *
67
+ * ⚠️ `applicationId` names the principal and is a name, not a credential. The key itself is not a
68
+ * parameter here and must never become one — audit is metadata, and this is exactly the row a
69
+ * "helpful" second field would leak it into.
70
+ */
71
+ recordAgentKeyRotation(input: {
72
+ auditId: string;
73
+ actorId: string;
74
+ nodeId: string;
75
+ applicationId: string;
76
+ occurredAt: string;
77
+ }): Promise<void>;
58
78
  getVersion(versionId: string): Promise<NodeVersion | null>;
59
79
  appendVersion(input: NewNodeVersion): Promise<"saved" | "conflict">;
60
80
  appendTableVersion(input: NewTableVersion): Promise<NodeVersion>;
@@ -184,6 +204,19 @@ export interface AgentApplications {
184
204
  id: string;
185
205
  key: string;
186
206
  }>;
207
+ /**
208
+ * A replacement key for an Application that already exists, invalidating every earlier one.
209
+ *
210
+ * ⚠️ Gate issues before it revokes, so a failed rotation leaves the old key working. That is what
211
+ * makes `rotate-key` a safe repair rather than a way to lock an agent out: the only state this
212
+ * can leave behind is "new key issued, runtime never got it", and rotating again fixes it.
213
+ */
214
+ rotateKey(input: {
215
+ token: string;
216
+ applicationId: string;
217
+ }): Promise<{
218
+ key: string;
219
+ }>;
187
220
  setEnabled(input: {
188
221
  token: string;
189
222
  applicationId: string;
@@ -198,6 +231,38 @@ export interface NodesDeps {
198
231
  content: ContentStore;
199
232
  applications: AgentApplications;
200
233
  agentRuntimeAvailable(): boolean;
234
+ /**
235
+ * The runtime's key store, as the tree needs it (D29, #207).
236
+ *
237
+ * ⚠️ This is where the Application key GOES, and it is the only place it goes. Gate hands it out
238
+ * once, this port carries it into the agent's Durable Object over the service binding, and the
239
+ * local variable that held it dies with the call. Nothing in this file writes it to D1, R2, an
240
+ * audit row, a log line or a response — the proof is the absence of the value in every argument
241
+ * every port was called with, not a redaction somebody could forget.
242
+ */
243
+ agentKeys: {
244
+ store(input: {
245
+ token: string;
246
+ agentId: string;
247
+ key: string;
248
+ }): Promise<void>;
249
+ };
250
+ /**
251
+ * The runtime's alarm, brought in line with the definition that was just written (#214).
252
+ *
253
+ * ⚠️ Required, not optional, and it carries no schedules. The runtime arms its alarm only when
254
+ * something tells it to look — a chat turn, a resume, a manual run, or this call — so a definition
255
+ * written without it leaves an agent whose schedules are visible in its profile and fire never.
256
+ * What travels is the agent's ID and the caller's bearer; the runtime re-reads the definition from
257
+ * Intel with the agent's own token, which is what keeps a schedule something that was written
258
+ * before it can be acted on.
259
+ */
260
+ agentSchedules: {
261
+ sync(input: {
262
+ token: string;
263
+ agentId: string;
264
+ }): Promise<void>;
265
+ };
201
266
  id(): string;
202
267
  now(): Date;
203
268
  externalFlowCallers(actor: Actor, folderId: string): Promise<{
@@ -205,6 +270,14 @@ export interface NodesDeps {
205
270
  hidden: number;
206
271
  }>;
207
272
  flowNodeReferences(actor: Actor, folderId: string): Promise<string[]>;
273
+ /**
274
+ * The handles of the MCP servers this actor reaches through the portal right now (D30).
275
+ *
276
+ * ⚠️ Injected as a live question, never as a stored list. Delegating a server is only allowed to
277
+ * somebody who has it themselves, and "has it" can only be answered by asking the portal at the
278
+ * moment of the save — a mirrored table here would be the permission mirror ADR-0003 removed.
279
+ */
280
+ toolServers(actor: Actor): Promise<string[]>;
208
281
  hash(content: string | Uint8Array): Promise<string>;
209
282
  indexing: {
210
283
  enqueue(versionId: string): Promise<void>;
@@ -254,14 +327,29 @@ export interface NodeService {
254
327
  redefineTable(actor: Actor, input: RedefineTableInput): Promise<NodeTable>;
255
328
  getAgent(actor: Actor, input: GetAgentInput): Promise<NodeAgent>;
256
329
  /**
257
- * An agent node, its first definition, and the Gate Application it runs as (#182).
330
+ * An agent node, its first definition, and the Gate Application it runs as (#182, D29).
258
331
  *
259
- * ⚠️ The answer carries the Application key ONCE. It is the only value any Intel service returns
260
- * that is a credential, it is never written down on the way out, and the caller who receives it
261
- * is the only one who will ever see it.
332
+ * ⚠️ The answer carries NO key. The one Gate issues goes straight into the agent runtime over the
333
+ * service binding, inside this call, and the caller never sees it which is what makes an agent
334
+ * created through the screen able to run without a terminal step (#200, #207).
262
335
  */
263
336
  createAgent(actor: Actor, input: CreateAgentInput, caller: GateCaller): Promise<CreatedAgent>;
264
- saveAgentDefinition(actor: Actor, input: SaveAgentDefinitionInput): Promise<NodeAgent>;
337
+ /**
338
+ * Replace the key of the Application an agent runs as, and give the new one to the runtime.
339
+ *
340
+ * ⚠️ The repair path for an agent that answers `agent_key_missing`, and the only one: Gate keeps
341
+ * a key hashed, so an agent whose Durable Object lost its key cannot be handed the old one back.
342
+ * It is a write on the agent node — `knowledge/write` and the resource ACL — not a run.
343
+ */
344
+ rotateAgentKey(actor: Actor, input: RotateAgentKeyInput, caller: GateCaller): Promise<AgentKeyRotated>;
345
+ /**
346
+ * A new definition version for an agent, and the alarm that goes with it (#214).
347
+ *
348
+ * ⚠️ It takes the caller's bearer for the same reason `createAgent` does: the runtime is told to
349
+ * re-arm over the service binding, and what travels on that call is the person behind this request
350
+ * — never a service key (D19).
351
+ */
352
+ saveAgentDefinition(actor: Actor, input: SaveAgentDefinitionInput, caller: GateCaller): Promise<NodeAgent>;
265
353
  listAgents(actor: Actor, input: ListAgentsInput): Promise<AgentList>;
266
354
  getAttachment(actor: Actor, nodeId: string): Promise<NodeAttachment>;
267
355
  readAttachment(actor: Actor, nodeId: string): Promise<NodeAttachmentBody>;
@@ -0,0 +1,46 @@
1
+ import { type ToolServer } from "@anchrd/intel-contract";
2
+ /**
3
+ * The portal's own directory tool. It is an ordinary entry in `tools/list`, so it is reached the
4
+ * same way every other tool is: `tools/call` with the asking user's portal token.
5
+ *
6
+ * ⚠️ Its presence in the live list is also the permission check. A deployment whose portal does not
7
+ * offer it, or a user whose Access policies hide it, gets no server list at all — and therefore
8
+ * cannot delegate anything. That is deliberate: the alternative would be inventing the server list
9
+ * out of tool names, which is exactly the guess this module exists to avoid.
10
+ */
11
+ export declare const ServerDirectoryTool = "portal_list_servers";
12
+ export interface ToolServerDirectoryAnswer {
13
+ isError: boolean;
14
+ content: unknown[];
15
+ structuredContent?: unknown;
16
+ }
17
+ /**
18
+ * The servers this user reaches: the portal's directory, confirmed against the portal's live tool
19
+ * list.
20
+ *
21
+ * Two sets come out of one answer, and keeping them apart is the whole of this function:
22
+ *
23
+ * - **`declared`** — every identifier the directory mentions at all, disabled rows included. It is
24
+ * the universe `serverOf` attributes against, and it must be as WIDE as possible. ⚠️ Narrowing it
25
+ * widens the delegation: drop `wiki_extra` because the portal marked it disabled, and the live
26
+ * tool `wiki_extra__read` falls back onto `wiki` — a tool of a server nobody delegated, handed to
27
+ * an agent that was given `wiki`. Filtering therefore happens after attribution, never before it.
28
+ * - **the returned list** — what may actually be delegated: enabled, and confirmed by at least one
29
+ * live tool. Intel cannot prove that a directory identifier is the same string the namespace uses,
30
+ * so it only ever offers handles it has just seen work.
31
+ *
32
+ * ⚠️ What remains outside Intel's reach: a directory that omits a server whose tools ARE in
33
+ * `tools/list`. Nothing in the answer distinguishes that from a tool of the enclosing namespace, and
34
+ * the portal is the authority on its own servers — so an incomplete directory widens by exactly one
35
+ * namespace level, and it is a portal defect rather than something this code can detect.
36
+ */
37
+ export interface ToolServerDirectory {
38
+ /** Every identifier the directory mentioned. Attribute against this, never against `servers`. */
39
+ declared: string[];
40
+ /** What may be offered and delegated: enabled, and confirmed by a live tool. */
41
+ servers: ToolServer[];
42
+ }
43
+ export declare function toolServersFrom(input: {
44
+ directory: ToolServerDirectoryAnswer;
45
+ toolNames: string[];
46
+ }): ToolServerDirectory | null;
@@ -0,0 +1,114 @@
1
+ import { serverOf } from "@anchrd/intel-contract";
2
+ import { z } from "zod";
3
+ /**
4
+ * The portal's own directory tool. It is an ordinary entry in `tools/list`, so it is reached the
5
+ * same way every other tool is: `tools/call` with the asking user's portal token.
6
+ *
7
+ * ⚠️ Its presence in the live list is also the permission check. A deployment whose portal does not
8
+ * offer it, or a user whose Access policies hide it, gets no server list at all — and therefore
9
+ * cannot delegate anything. That is deliberate: the alternative would be inventing the server list
10
+ * out of tool names, which is exactly the guess this module exists to avoid.
11
+ */
12
+ export const ServerDirectoryTool = "portal_list_servers";
13
+ /**
14
+ * What the portal answers about one server, read as tolerantly as possible.
15
+ *
16
+ * ⚠️ Only the shape is assumed, never the vocabulary. The portal is somebody else's product and its
17
+ * payload is not part of any contract Intel owns, so every field is optional and the answer is
18
+ * accepted from `structuredContent`, from `{ servers: [...] }` or from a JSON text block. What
19
+ * makes a row usable is not that it parsed but that its identifier is confirmed against the live
20
+ * tool list below.
21
+ */
22
+ const DirectoryRow = z.looseObject({
23
+ id: z.string().optional(),
24
+ name: z.string().optional(),
25
+ enabled: z.boolean().optional(),
26
+ });
27
+ const DirectoryRows = z.array(DirectoryRow);
28
+ const DirectoryEnvelope = z.looseObject({ servers: DirectoryRows });
29
+ /**
30
+ * Every place the portal could reasonably have put its list, tried in order. An MCP tool result is
31
+ * either structured or text, and a server that answers with `{ servers: [...] }` is as likely as
32
+ * one that answers with a bare array.
33
+ */
34
+ function rows(answer) {
35
+ const candidates = [answer.structuredContent];
36
+ for (const block of answer.content) {
37
+ if (typeof block === "object" && block !== null && "text" in block) {
38
+ const text = block.text;
39
+ if (typeof text !== "string")
40
+ continue;
41
+ try {
42
+ candidates.push(JSON.parse(text));
43
+ }
44
+ catch {
45
+ // A text block that is not JSON is prose, not a directory. Skip it rather than fail: the
46
+ // portal may well add a human sentence beside the payload.
47
+ }
48
+ }
49
+ }
50
+ for (const candidate of candidates) {
51
+ const bare = DirectoryRows.safeParse(candidate);
52
+ if (bare.success)
53
+ return bare.data;
54
+ const wrapped = DirectoryEnvelope.safeParse(candidate);
55
+ if (wrapped.success)
56
+ return wrapped.data.servers;
57
+ }
58
+ return null;
59
+ }
60
+ /**
61
+ * ⚠️ The rule itself — longest match against declared handles, never a cut at an underscore — is
62
+ * `serverOf` in `@anchrd/intel-contract`, because the tools screen has to read a name exactly the
63
+ * way the delegation does (#212). What stays here is what the rule is USED for: a tool whose prefix
64
+ * matches no declared handle belongs to no server and can never be delegated, which is what keeps
65
+ * the portal's own management tools (`portal_list_servers`, `portal_toggle_servers`) out of an
66
+ * agent's reach — the portal does not list itself as an upstream server.
67
+ */
68
+ /**
69
+ * ⚠️ A handle that would carry the portal's OWN tools is never a server anybody can be given.
70
+ *
71
+ * The portal does not list itself as an upstream, so in practice `portal` never appears in the
72
+ * directory — but "in practice" is not a guard. One row called `portal` (an upstream a customer
73
+ * happens to name that way, a future portal that describes itself) would make `portal_toggle_servers`
74
+ * a delegable tool, and an agent could then switch its delegator's MCP servers on and off. The rule
75
+ * is written as a question about reach rather than as a blocklist of one string: if delegating this
76
+ * handle would hand over `portal_list_servers`, the handle is refused.
77
+ */
78
+ function ownsTheDirectory(handle) {
79
+ return ServerDirectoryTool.startsWith(`${handle}_`);
80
+ }
81
+ export function toolServersFrom(input) {
82
+ const parsed = rows(input.directory);
83
+ if (parsed === null)
84
+ return null;
85
+ // `id` and `name` are both offered as candidates because the portal's directory names them
86
+ // separately and only one of them is the namespace. The live list decides which.
87
+ const display = new Map();
88
+ const offerable = new Set();
89
+ for (const row of parsed) {
90
+ for (const candidate of [row.name, row.id]) {
91
+ if (candidate === undefined || candidate.length === 0)
92
+ continue;
93
+ if (ownsTheDirectory(candidate))
94
+ continue;
95
+ if (!display.has(candidate))
96
+ display.set(candidate, row.name ?? row.id ?? candidate);
97
+ if (row.enabled !== false)
98
+ offerable.add(candidate);
99
+ }
100
+ }
101
+ const counts = new Map();
102
+ for (const toolName of input.toolNames) {
103
+ const handle = serverOf(toolName, display.keys());
104
+ if (handle === null || !offerable.has(handle))
105
+ continue;
106
+ counts.set(handle, (counts.get(handle) ?? 0) + 1);
107
+ }
108
+ return {
109
+ declared: [...display.keys()],
110
+ servers: [...counts.entries()]
111
+ .map(([handle, toolCount]) => ({ handle, name: display.get(handle) ?? handle, toolCount }))
112
+ .sort((a, b) => a.handle.localeCompare(b.handle)),
113
+ };
114
+ }
@@ -1,10 +1,24 @@
1
+ import { serverOf } from "@anchrd/intel-contract";
1
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
2
3
  import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
4
+ import { ServerDirectoryTool, toolServersFrom, } from "./tool-servers/tool-servers.js";
3
5
  const CallTimeoutMs = 15_000;
4
6
  const MaxTools = 1_000;
5
7
  const MaxResultBytes = 1_000_000;
6
8
  // Refresh slightly early so a call cannot start with a token that expires mid-flight.
7
9
  const RefreshWindowMs = 30_000;
10
+ /**
11
+ * An agent that delegates nothing — no servers, or no delegator to act for.
12
+ *
13
+ * ⚠️ It is answered without touching the token store at all. `delegatedBy` is empty for an archived
14
+ * agent, one whose definition Intel could not read, and one that was never given tools; asking the
15
+ * store for the connection of user "" would be a lookup that can only ever fail, on a path that has
16
+ * already decided the answer.
17
+ */
18
+ function delegatesNothing(who) {
19
+ return (who.delegation !== null &&
20
+ (who.delegation.servers.length === 0 || who.delegation.delegatedBy.length === 0));
21
+ }
8
22
  export function createTools(deps) {
9
23
  function portal() {
10
24
  if (!deps.portalUrl || !deps.sourceAllowed(deps.portalUrl)) {
@@ -12,12 +26,23 @@ export function createTools(deps) {
12
26
  }
13
27
  return deps.portalUrl;
14
28
  }
29
+ // One lookup per request, before anything else happens: everything below has to know whether it
30
+ // is answering a person or an agent, and a second lookup could answer differently mid-call.
31
+ async function acting(actor) {
32
+ const delegation = await deps.delegation(actor.id);
33
+ return {
34
+ actor,
35
+ connectionOf: delegation?.delegatedBy ?? actor.id,
36
+ delegation,
37
+ };
38
+ }
15
39
  // Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
16
40
  // this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
17
41
  // operator token for everybody would make every catalog the same one and the portal's Access
18
- // policies decorative (ADR-0003).
19
- async function accessToken(actor) {
20
- const stored = await deps.tokens.read(actor.id);
42
+ // policies decorative (ADR-0003). For an agent the actor IS somebody else — the delegator — which
43
+ // is the whole of D30 and the reason this takes an `Acting` rather than a `ToolActor`.
44
+ async function accessToken(who) {
45
+ const stored = await deps.tokens.read(who.connectionOf);
21
46
  if (!stored) {
22
47
  throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
23
48
  }
@@ -33,20 +58,81 @@ export function createTools(deps) {
33
58
  // A token that cannot be renewed is dropped: leaving it would keep failing every call with a
34
59
  // stale credential. The browser answers this by signing in silently again (#60); an MCP
35
60
  // client sees the code and repeats its own authorization.
36
- await deps.tokens.clear(actor.id);
61
+ await deps.tokens.clear(who.connectionOf);
37
62
  throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
38
63
  }
39
- await deps.tokens.write(actor.id, refreshed);
64
+ await deps.tokens.write(who.connectionOf, refreshed);
40
65
  return refreshed.accessToken;
41
66
  }
42
- // One live tools/list is both the catalog and the authorization answer: the portal only returns
43
- // what this user may reach. Nothing here is cached as a permission.
44
- async function capabilities(actor) {
45
- const token = await accessToken(actor);
67
+ async function remoteTools(who) {
68
+ const token = await accessToken(who);
46
69
  const remote = await deps.remote.list(portal(), token, AbortSignal.timeout(CallTimeoutMs));
47
70
  if (remote.length > MaxTools) {
48
71
  throw new IntelError(502, "tool_catalog_too_large", "The portal returned too many tools");
49
72
  }
73
+ return remote;
74
+ }
75
+ /**
76
+ * The servers the portal says this connection reaches, confirmed against its live tool list.
77
+ *
78
+ * ⚠️ Fails rather than answering "none" when the portal keeps no directory. An empty list would
79
+ * be indistinguishable from "you have no servers", and the difference decides whether a screen
80
+ * says "connect something" or "this portal cannot be delegated from".
81
+ */
82
+ async function serverList(who, toolNames) {
83
+ if (!toolNames.includes(ServerDirectoryTool)) {
84
+ throw new IntelError(502, "tool_servers_unavailable", `The portal does not offer ${ServerDirectoryTool}, so its servers cannot be listed`);
85
+ }
86
+ const answer = await deps.remote.call({
87
+ url: portal(),
88
+ accessToken: await accessToken(who),
89
+ name: ServerDirectoryTool,
90
+ arguments: {},
91
+ signal: AbortSignal.timeout(CallTimeoutMs),
92
+ });
93
+ const directory = answer.isError ? null : toolServersFrom({ directory: answer, toolNames });
94
+ if (directory === null) {
95
+ throw new IntelError(502, "tool_servers_unavailable", "The portal did not answer with a server list this Intel can read");
96
+ }
97
+ return directory;
98
+ }
99
+ /**
100
+ * One live tools/list is both the catalog and the authorization answer: the portal only returns
101
+ * what this user may reach. Nothing here is cached as a permission.
102
+ *
103
+ * ⚠️ For an agent the list is cut to the delegated servers, and the cut is made against the
104
+ * portal's own directory rather than against the tool names — `tool-servers.ts` says why the
105
+ * namespace alone is not enough to attribute a tool. The second return value is the attribution
106
+ * itself, so the audit below names the server the cut actually used rather than guessing again.
107
+ */
108
+ async function capabilities(who) {
109
+ const delegated = who.delegation;
110
+ const serverOfTool = new Map();
111
+ if (delegatesNothing(who))
112
+ return { items: [], serverOfTool };
113
+ let remote = await remoteTools(who);
114
+ if (delegated) {
115
+ const directory = await serverList(who, remote.map((tool) => tool.name));
116
+ // ⚠️ Attributed against every identifier the portal DECLARED — including rows it marked
117
+ // disabled — and only then checked against the delegation. Attributing against the offerable
118
+ // subset (or against the delegation itself) would widen it: with `wiki` delegated and
119
+ // `wiki_extra` merely declared, `wiki_extra__read` starts with `wiki_` and would be handed
120
+ // over as a `wiki` tool. The longest match over the widest declared set is the only reading
121
+ // that no missing row can loosen.
122
+ const delegatedSet = new Set(delegated.servers);
123
+ // ⚠️ And the attribution is then required to land on a server that is still OFFERABLE — the
124
+ // narrow set, enabled and confirmed. Wide for attribution, narrow for permission: a portal
125
+ // that switches a server off takes it away from the agent here, which is revocation reaching
126
+ // an agent without anybody touching its definition (D30).
127
+ const offerable = new Set(directory.servers.map((server) => server.handle));
128
+ remote = remote.filter((tool) => {
129
+ const handle = serverOf(tool.name, directory.declared);
130
+ if (handle === null || !delegatedSet.has(handle) || !offerable.has(handle))
131
+ return false;
132
+ serverOfTool.set(tool.name, handle);
133
+ return true;
134
+ });
135
+ }
50
136
  const items = [];
51
137
  for (const tool of remote) {
52
138
  items.push({
@@ -59,27 +145,63 @@ export function createTools(deps) {
59
145
  }),
60
146
  });
61
147
  }
62
- return items;
148
+ return { items, serverOfTool };
149
+ }
150
+ /**
151
+ * ⚠️ The refusal that costs nothing. A delegated caller naming a tool outside every delegated
152
+ * namespace is turned away here — before the token store, before `tools/list`, before the portal
153
+ * hears anything at all. The authoritative cut still happens in `capabilities`, against the
154
+ * portal's directory; this one exists so the portal is never touched on behalf of a call that was
155
+ * always going to be refused, and so the refusal can name the tool and the servers (D30).
156
+ */
157
+ function requireDelegated(who, name) {
158
+ const delegated = who.delegation;
159
+ if (!delegated)
160
+ return;
161
+ if (delegatesNothing(who)) {
162
+ throw new IntelError(403, "tool_not_delegated", `${name} is not available: this agent has no delegated MCP servers`);
163
+ }
164
+ if (serverOf(name, delegated.servers) === null) {
165
+ throw new IntelError(403, "tool_not_delegated", `${name} is not part of this agent's delegated servers (${delegated.servers.join(", ")})`);
166
+ }
63
167
  }
64
- async function requireCapability(actor, name) {
65
- const found = (await capabilities(actor)).find((capability) => capability.name === name);
168
+ async function requireCapability(who, name) {
169
+ const reachable = await capabilities(who);
170
+ const found = reachable.items.find((capability) => capability.name === name);
66
171
  if (!found) {
67
172
  throw new IntelError(404, "tool_not_available", `Tool ${name} is not available to you`);
68
173
  }
69
- return found;
174
+ return { capability: found, server: reachable.serverOfTool.get(name) };
70
175
  }
71
- async function call(actor, input) {
72
- if (!actor.canExecute) {
176
+ async function call(who, input) {
177
+ if (!who.actor.canExecute) {
73
178
  throw new IntelError(403, "tool_execute_forbidden", "Tool execution permission is required");
74
179
  }
75
- const capability = await requireCapability(actor, input.name);
180
+ requireDelegated(who, input.name);
181
+ const { capability, server } = await requireCapability(who, input.name);
76
182
  const validation = deps.validate(capability.inputSchema, input.arguments);
77
183
  if (!validation.valid) {
78
184
  throw new IntelError(400, "tool_arguments_invalid", validation.detail ?? "Invalid arguments");
79
185
  }
186
+ // ⚠️ Written before the call, not after it: an audit that only records what succeeded is a
187
+ // record of the harmless half. Both principals are named — the agent that acted and the person
188
+ // whose connection carried it (D30) — and no argument is.
189
+ //
190
+ // ⚠️ The server is the one the CUT used, carried out of `capabilities`, not a second guess made
191
+ // against the delegated handles. Re-deriving it here would name `wiki` for a tool the cut
192
+ // attributed to `wiki_extra`, and an audit row that names the wrong system is worse than none.
193
+ if (who.delegation) {
194
+ await deps.audit({
195
+ agentId: who.delegation.agentId,
196
+ applicationId: who.actor.id,
197
+ delegatedBy: who.delegation.delegatedBy,
198
+ server: server ?? "",
199
+ tool: capability.name,
200
+ });
201
+ }
80
202
  const result = await deps.remote.call({
81
203
  url: portal(),
82
- accessToken: await accessToken(actor),
204
+ accessToken: await accessToken(who),
83
205
  name: capability.name,
84
206
  arguments: input.arguments,
85
207
  signal: AbortSignal.timeout(CallTimeoutMs),
@@ -89,41 +211,78 @@ export function createTools(deps) {
89
211
  }
90
212
  return result;
91
213
  }
214
+ // A token that is gone or beyond renewal is the same answer as never having signed in, so reading
215
+ // a catalog reports it as a state. Only a portal that does not answer stays an error — the view
216
+ // has to tell "sign in again" apart from "the portal failed", and a 401 here would otherwise look
217
+ // like an expired Intel session to the browser.
218
+ function disconnected(error) {
219
+ return (error instanceof IntelError &&
220
+ (error.code === "portal_not_connected" || error.code === "portal_reconnect_required"));
221
+ }
92
222
  return {
93
223
  async catalog(actor) {
94
- const stored = await deps.tokens.read(actor.id);
224
+ const who = await acting(actor);
225
+ if (delegatesNothing(who))
226
+ return { portalConnected: true, items: [] };
227
+ const stored = await deps.tokens.read(who.connectionOf);
95
228
  // No portal sign-in yet is a normal state, not an error: the browser answers it by running
96
- // the silent sign-in and asking again (#60).
229
+ // the silent sign-in and asking again (#60). For an agent it is the ordinary shape of
230
+ // revocation — the delegator disconnected, so the agent reaches nothing (D30).
231
+ if (!stored)
232
+ return { portalConnected: false, items: [] };
233
+ try {
234
+ return { portalConnected: true, items: (await capabilities(who)).items };
235
+ }
236
+ catch (error) {
237
+ if (disconnected(error))
238
+ return { portalConnected: false, items: [] };
239
+ throw error;
240
+ }
241
+ },
242
+ async servers(actor) {
243
+ const who = await acting(actor);
244
+ if (delegatesNothing(who))
245
+ return { portalConnected: true, items: [] };
246
+ const stored = await deps.tokens.read(who.connectionOf);
97
247
  if (!stored)
98
248
  return { portalConnected: false, items: [] };
99
249
  try {
100
- return { portalConnected: true, items: await capabilities(actor) };
250
+ const remote = await remoteTools(who);
251
+ const { servers } = await serverList(who, remote.map((tool) => tool.name));
252
+ // ⚠️ An agent sees its own delegation, never the delegator's whole shelf. Its catalog is cut
253
+ // anyway, so the uncut list would grant nothing — it would only tell an agent, and through
254
+ // it a model, which other systems the person it acts for is connected to.
255
+ const delegated = who.delegation;
256
+ return {
257
+ portalConnected: true,
258
+ items: delegated
259
+ ? servers.filter((server) => delegated.servers.includes(server.handle))
260
+ : servers,
261
+ };
101
262
  }
102
263
  catch (error) {
103
- // A token that is gone or beyond renewal is the same answer as never having signed in, so
104
- // reading the catalog reports it as a state. Only a portal that does not answer stays an
105
- // error — the view has to tell "sign in again" apart from "the portal failed", and a 401
106
- // here would otherwise look like an expired Intel session to the browser.
107
- if (error instanceof IntelError &&
108
- (error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
264
+ if (disconnected(error))
109
265
  return { portalConnected: false, items: [] };
110
- }
111
266
  throw error;
112
267
  }
113
268
  },
114
- execute: async (actor, input) => await call(actor, input),
269
+ execute: async (actor, input) => await call(await acting(actor), input),
115
270
  async test(actor, input) {
116
- const capability = await requireCapability(actor, input.name);
271
+ const who = await acting(actor);
272
+ requireDelegated(who, input.name);
273
+ const { capability } = await requireCapability(who, input.name);
117
274
  if (capability.annotations.readOnlyHint !== true ||
118
275
  capability.annotations.destructiveHint === true) {
119
276
  throw new IntelError(409, "tool_test_unsafe", "Only explicitly read-only, non-destructive tools can run in the test surface");
120
277
  }
121
- return await call(actor, input);
278
+ return await call(who, input);
122
279
  },
123
280
  async unavailable(actor, names) {
124
281
  if (names.length === 0)
125
282
  return [];
126
- const available = new Set((await capabilities(actor)).map((capability) => capability.name));
283
+ const who = await acting(actor);
284
+ const reachable = await capabilities(who);
285
+ const available = new Set(reachable.items.map((capability) => capability.name));
127
286
  return names.filter((name) => !available.has(name));
128
287
  },
129
288
  };
@@ -1,4 +1,4 @@
1
- import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolTestResult } from "@anchrd/intel-contract";
1
+ import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolServerCatalog, ToolTestResult } from "@anchrd/intel-contract";
2
2
  export interface ToolActor {
3
3
  id: string;
4
4
  email: string;
@@ -35,6 +35,25 @@ export interface RemoteTools {
35
35
  signal: AbortSignal;
36
36
  }): Promise<ToolTestResult>;
37
37
  }
38
+ /**
39
+ * What an agent's definition delegates, resolved from the Gate Application the caller authenticated
40
+ * as (D30). `null` from the port means "this caller is not an agent" — the ordinary user path.
41
+ *
42
+ * ⚠️ Read fresh on every call and never cached as a permission. It is the same rule the catalog
43
+ * follows: the answer has to be able to change between two runs without anybody editing anything.
44
+ */
45
+ export interface ToolDelegation {
46
+ agentId: string;
47
+ delegatedBy: string;
48
+ servers: string[];
49
+ }
50
+ export interface ToolAuditEvent {
51
+ agentId: string;
52
+ applicationId: string;
53
+ delegatedBy: string;
54
+ server: string;
55
+ tool: string;
56
+ }
38
57
  export interface ToolDeps {
39
58
  portalUrl: string | null;
40
59
  remote: RemoteTools;
@@ -47,9 +66,12 @@ export interface ToolDeps {
47
66
  valid: boolean;
48
67
  detail?: string;
49
68
  };
69
+ delegation(applicationId: string): Promise<ToolDelegation | null>;
70
+ audit(event: ToolAuditEvent): Promise<void>;
50
71
  }
51
72
  export interface ToolService {
52
73
  catalog(actor: ToolActor): Promise<ToolCatalog>;
74
+ servers(actor: ToolActor): Promise<ToolServerCatalog>;
53
75
  execute(actor: ToolActor, input: ExecuteToolInput): Promise<ToolTestResult>;
54
76
  test(actor: ToolActor, input: TestToolInput): Promise<ToolTestResult>;
55
77
  unavailable(actor: ToolActor, names: string[]): Promise<string[]>;