@stage-labs/metro 0.1.0-beta.292 → 0.1.0-beta.294
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +12 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +20 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +4 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +12 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +16 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +8 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +21 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -15
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +1 -85
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
- package/runtime/node_modules/@metro-labs/http/package.json +1 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +4 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +2 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +39 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +94 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { JSONRPCNotificationSchema, ServerNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import type { MigrationOperation } from './migration-operations.js';
|
|
5
|
+
|
|
6
|
+
export const isRelaySse = (contentType: string | null | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
|
|
7
|
+
const MAX_FRAME = 64 * 1024;
|
|
8
|
+
const NOTIFICATIONS = new Set(['notifications/tools/list_changed', 'notifications/resources/list_changed', 'notifications/prompts/list_changed', 'notifications/resources/updated', 'notifications/message']);
|
|
9
|
+
|
|
10
|
+
export class PassiveSseListener {
|
|
11
|
+
forwarding = false;
|
|
12
|
+
private pending: Buffer[] = [];
|
|
13
|
+
private length = 0;
|
|
14
|
+
private data: string[] = [];
|
|
15
|
+
private fields = false;
|
|
16
|
+
private size = 0;
|
|
17
|
+
private unsafe = false;
|
|
18
|
+
private skipLf = false;
|
|
19
|
+
private first = true;
|
|
20
|
+
private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
21
|
+
|
|
22
|
+
idle(): boolean { return !this.unsafe && this.length === 0 && !this.fields && !this.forwarding; }
|
|
23
|
+
keepaliveAllowed(): boolean { return this.unsafe || this.idle(); }
|
|
24
|
+
|
|
25
|
+
observe(chunk: Uint8Array): void {
|
|
26
|
+
if (this.unsafe) return;
|
|
27
|
+
this.scan(chunk);
|
|
28
|
+
if (this.unsafe) { this.pending = []; this.length = 0; this.data = []; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private append(bytes: Uint8Array): void {
|
|
32
|
+
this.length += bytes.length;
|
|
33
|
+
if (this.length + this.size > MAX_FRAME) this.unsafe = true;
|
|
34
|
+
else if (bytes.length > 0) this.pending.push(Buffer.from(bytes));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private scan(bytes: Uint8Array): void {
|
|
38
|
+
let start = 0;
|
|
39
|
+
for (let at = 0; at < bytes.length; at += 1) {
|
|
40
|
+
if (at - start + this.length + this.size > MAX_FRAME) { this.unsafe = true; return; }
|
|
41
|
+
const byte = bytes[at];
|
|
42
|
+
if (this.skipLf && byte === 10) { this.skipLf = false; start = at + 1; continue; }
|
|
43
|
+
this.skipLf = false;
|
|
44
|
+
if (byte !== 10 && byte !== 13) continue;
|
|
45
|
+
this.append(bytes.subarray(start, at));
|
|
46
|
+
try { this.line(this.decoder.decode(Buffer.concat(this.pending, this.length)), this.length); }
|
|
47
|
+
catch { this.unsafe = true; }
|
|
48
|
+
this.pending = []; this.length = 0;
|
|
49
|
+
this.skipLf = byte === 13;
|
|
50
|
+
start = at + 1;
|
|
51
|
+
if (this.unsafe) return;
|
|
52
|
+
}
|
|
53
|
+
this.append(bytes.subarray(start));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private line(raw: string, length: number): void {
|
|
57
|
+
const line = this.first && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
58
|
+
this.first = false;
|
|
59
|
+
if (line === '') {
|
|
60
|
+
if (this.data.length > 0 && !this.notification()) this.unsafe = true;
|
|
61
|
+
this.data = []; this.fields = false; this.size = 0;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (line.startsWith(':')) return;
|
|
65
|
+
this.size += length;
|
|
66
|
+
if (this.size > MAX_FRAME) { this.unsafe = true; return; }
|
|
67
|
+
this.fields = true;
|
|
68
|
+
const colon = line.indexOf(':');
|
|
69
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
70
|
+
const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
|
|
71
|
+
this.field(field, value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private field(field: string, value: string): void {
|
|
75
|
+
if (field === 'data') this.data.push(value);
|
|
76
|
+
else if (field === 'event') { if (value !== '' && value !== 'message') this.unsafe = true; }
|
|
77
|
+
else if (field === 'retry') { if (!/^\d+$/.test(value)) this.unsafe = true; }
|
|
78
|
+
else this.unsafe = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private notification(): boolean {
|
|
82
|
+
const text = this.data.join('\n');
|
|
83
|
+
if (text === '') return true;
|
|
84
|
+
const value: unknown = JSON.parse(text);
|
|
85
|
+
return isRecord(value) && typeof value.method === 'string' && NOTIFICATIONS.has(value.method)
|
|
86
|
+
&& JSONRPCNotificationSchema.safeParse(value).success && ServerNotificationSchema.safeParse(value).success;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function passiveRelayListener(req: IncomingMessage, res: ServerResponse, upstream: Response, operation: MigrationOperation, stop: () => void): PassiveSseListener | undefined {
|
|
91
|
+
if (req.method !== 'GET' || req.headers['last-event-id'] !== undefined || req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) !== 0) return undefined;
|
|
92
|
+
if (upstream.status !== 200 || upstream.body === null || !isRelaySse(upstream.headers.get('content-type'))) return undefined;
|
|
93
|
+
const listener = new PassiveSseListener();
|
|
94
|
+
operation.passiveListener({ idle: () => listener.idle() && res.writableLength === 0 && !res.destroyed, stop });
|
|
95
|
+
return listener;
|
|
96
|
+
}
|
|
@@ -3,6 +3,7 @@ import { whyUnreachable } from '@metro-labs/http/connector-reach';
|
|
|
3
3
|
import { noteHealth } from './health.js';
|
|
4
4
|
import type { RelayTarget } from './relay-target.js';
|
|
5
5
|
import type { RelaySession } from './relay-session.js';
|
|
6
|
+
import { isRelaySse, passiveRelayListener, type PassiveSseListener } from './relay-passive.js';
|
|
6
7
|
import { isOrgRelayId } from './relay-id.js';
|
|
7
8
|
import { exchangeOrg } from './org-relay.js';
|
|
8
9
|
import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
|
|
@@ -18,7 +19,7 @@ export interface RelayApiDeps {
|
|
|
18
19
|
identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
|
|
19
20
|
signedOut: (connectorId: string) => void;
|
|
20
21
|
blocked?: BlockedReason;
|
|
21
|
-
open?: (connectorId: string) => RelaySession | null;
|
|
22
|
+
open?: (connectorId: string) => RelaySession | Promise<RelaySession> | null;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const ID_PATH_RE = /^\/relay\/((?:org-)?[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
@@ -131,12 +132,12 @@ interface Keepalive {
|
|
|
131
132
|
stop: () => void;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
function startKeepalive(res: ServerResponse): Keepalive {
|
|
135
|
+
function startKeepalive(res: ServerResponse, listener?: PassiveSseListener): Keepalive {
|
|
135
136
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
136
137
|
const arm = (): void => {
|
|
137
138
|
timer = setTimeout(() => {
|
|
138
139
|
try {
|
|
139
|
-
res.write(': keepalive\n\n');
|
|
140
|
+
if (listener?.keepaliveAllowed() !== false) res.write(': keepalive\n\n');
|
|
140
141
|
} catch {
|
|
141
142
|
log.debug('relay: keepalive write failed');
|
|
142
143
|
}
|
|
@@ -172,13 +173,17 @@ async function pumpBody(
|
|
|
172
173
|
res: ServerResponse,
|
|
173
174
|
body: ReadableStream<Uint8Array>,
|
|
174
175
|
touch: () => void,
|
|
176
|
+
listener?: PassiveSseListener,
|
|
175
177
|
): Promise<void> {
|
|
176
178
|
const reader = body.getReader();
|
|
177
179
|
for (;;) {
|
|
178
180
|
const { done, value } = await reader.read();
|
|
179
181
|
if (done) return;
|
|
182
|
+
listener?.observe(value);
|
|
180
183
|
touch();
|
|
184
|
+
if (listener !== undefined) listener.forwarding = true;
|
|
181
185
|
if (!res.write(value)) await drainedOrClosed(res);
|
|
186
|
+
if (listener !== undefined) listener.forwarding = false;
|
|
182
187
|
}
|
|
183
188
|
}
|
|
184
189
|
|
|
@@ -191,22 +196,23 @@ function passedHeaders(upstream: Response): Record<string, string> {
|
|
|
191
196
|
return headers;
|
|
192
197
|
}
|
|
193
198
|
|
|
194
|
-
async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
|
|
199
|
+
async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[], listener?: PassiveSseListener): Promise<void> {
|
|
195
200
|
const headers = passedHeaders(upstream);
|
|
196
201
|
if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
|
|
197
202
|
await mergeBlocked(res, upstream, headers, answers);
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
|
-
const sse = (headers['content-type']
|
|
205
|
+
const sse = isRelaySse(headers['content-type']);
|
|
201
206
|
if (sse) headers['x-accel-buffering'] = 'no';
|
|
202
207
|
res.writeHead(upstream.status, headers);
|
|
208
|
+
if (sse) res.flushHeaders();
|
|
203
209
|
if (upstream.body === null) {
|
|
204
210
|
res.end();
|
|
205
211
|
return;
|
|
206
212
|
}
|
|
207
|
-
const keepalive = sse ? startKeepalive(res) : null;
|
|
213
|
+
const keepalive = sse ? startKeepalive(res, listener) : null;
|
|
208
214
|
try {
|
|
209
|
-
await pumpBody(res, upstream.body, () => keepalive?.touch());
|
|
215
|
+
await pumpBody(res, upstream.body, () => keepalive?.touch(), listener);
|
|
210
216
|
} finally {
|
|
211
217
|
keepalive?.stop();
|
|
212
218
|
}
|
|
@@ -262,7 +268,7 @@ async function relayExchange(
|
|
|
262
268
|
res.socket?.removeListener('close', bail);
|
|
263
269
|
});
|
|
264
270
|
try {
|
|
265
|
-
await relayOnce(req, res, connectorId, deps, control
|
|
271
|
+
await relayOnce(req, res, connectorId, deps, control);
|
|
266
272
|
} catch (err) {
|
|
267
273
|
if (!control.signal.aborted) throw err;
|
|
268
274
|
log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
|
|
@@ -276,6 +282,7 @@ async function deliver(
|
|
|
276
282
|
deps: RelayApiDeps,
|
|
277
283
|
out: Exchanged,
|
|
278
284
|
answers: Record<string, unknown>[],
|
|
285
|
+
listener?: PassiveSseListener,
|
|
279
286
|
): Promise<void> {
|
|
280
287
|
if (out.kind === 'missing') {
|
|
281
288
|
answer(res, 404, { error: 'no such connector' });
|
|
@@ -295,11 +302,11 @@ async function deliver(
|
|
|
295
302
|
}
|
|
296
303
|
const { status } = out.upstream;
|
|
297
304
|
noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
|
|
298
|
-
await pipe(res, out.upstream, answers);
|
|
305
|
+
await pipe(res, out.upstream, answers, listener);
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
|
|
302
|
-
const session = deps.open?.(id);
|
|
309
|
+
const session = await deps.open?.(id);
|
|
303
310
|
if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
|
|
304
311
|
try {
|
|
305
312
|
const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => {
|
|
@@ -316,8 +323,9 @@ async function relayOnce(
|
|
|
316
323
|
res: ServerResponse,
|
|
317
324
|
connectorId: string,
|
|
318
325
|
deps: RelayApiDeps,
|
|
319
|
-
|
|
326
|
+
control: AbortController,
|
|
320
327
|
): Promise<void> {
|
|
328
|
+
const { signal } = control;
|
|
321
329
|
const read = req.method === 'POST' ? await readCapped(req) : null;
|
|
322
330
|
const dir = deps.migrationDir ?? agentsDir();
|
|
323
331
|
if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
|
|
@@ -328,7 +336,8 @@ async function relayOnce(
|
|
|
328
336
|
return;
|
|
329
337
|
}
|
|
330
338
|
const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
|
|
331
|
-
|
|
339
|
+
const listener = out.kind === 'response' ? passiveRelayListener(req, res, out.upstream, operation, () => { control.abort(); }) : undefined;
|
|
340
|
+
await deliver(res, connectorId, deps, out, screened?.answers ?? [], listener);
|
|
332
341
|
});
|
|
333
342
|
}
|
|
334
343
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { serverKeysOf, type PluginRow } from './plugin-sync.js';
|
|
7
|
+
|
|
8
|
+
const invalid = (): Error => new Error('Invalid connector server keys. Restore the saved namespace mapping and initialization marker.');
|
|
9
|
+
|
|
10
|
+
function readKeys(path: string, marker: string): Map<string, string> | undefined {
|
|
11
|
+
try {
|
|
12
|
+
const marked = existsSync(marker);
|
|
13
|
+
if (marked && readFileSync(marker, 'utf8') !== '1\n') throw invalid();
|
|
14
|
+
if (!existsSync(path)) {
|
|
15
|
+
if (marked) throw invalid();
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
if (!marked) throw invalid();
|
|
19
|
+
return parseKeys(readFileSync(path, 'utf8'));
|
|
20
|
+
} catch { throw invalid(); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseKeys(text: string): Map<string, string> {
|
|
24
|
+
const raw: unknown = JSON.parse(text);
|
|
25
|
+
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.keys)) throw invalid();
|
|
26
|
+
const keys = new Map<string, string>();
|
|
27
|
+
const reserved = new Set<string>();
|
|
28
|
+
for (const [id, key] of Object.entries(raw.keys)) {
|
|
29
|
+
if (typeof key !== 'string' || !/^[a-z0-9-]+$/.test(key) || reserved.has(key)) throw invalid();
|
|
30
|
+
reserved.add(key);
|
|
31
|
+
keys.set(id, key);
|
|
32
|
+
}
|
|
33
|
+
return keys;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function persistentServerKeys(rows: PluginRow[], dir = agentsDir(), published: PluginRow[] = rows): Map<string, string> {
|
|
37
|
+
const path = join(dir, 'connector-server-keys.json');
|
|
38
|
+
const marker = join(dir, 'connector-server-keys.initialized');
|
|
39
|
+
const saved = readKeys(path, marker);
|
|
40
|
+
const keys = serverKeysOf(rows, saved, published);
|
|
41
|
+
if (keys.size > 0 && keys.size !== saved?.size) {
|
|
42
|
+
if (saved === undefined) writeSecure(marker, '1\n');
|
|
43
|
+
writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
return keys;
|
|
46
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { closeSync, fsyncSync, openSync } from 'node:fs';
|
|
3
3
|
import { publishLocalConnectors } from './publication.js';
|
|
4
|
+
import { persistentServerKeys } from './server-keys.js';
|
|
4
5
|
import { assertLocalMigrationEdit, localMigrationEnabled, localMigrationIdle, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
|
|
5
6
|
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
6
7
|
import type { RelayTarget } from './relay-target.js';
|
|
@@ -116,6 +117,8 @@ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function writeRows(dir: string, rows: LocalConnectorRow[]): void {
|
|
120
|
+
persistentServerKeys(readLocalConnectors(dir), dir);
|
|
121
|
+
persistentServerKeys(rows, dir);
|
|
119
122
|
writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
|
|
120
123
|
if (dir !== agentsDir()) return;
|
|
121
124
|
publishLocalConnectors(rows);
|
|
@@ -130,7 +133,7 @@ function rowOrThrow(id: string, dir: string): LocalConnectorRow {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
function replace(dir: string, row: LocalConnectorRow, refresh = false): Connector {
|
|
133
|
-
if (!refresh) assertLocalMigrationEdit(row.id,
|
|
136
|
+
if (!refresh) assertLocalMigrationEdit(row.id, dir);
|
|
134
137
|
writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
|
|
135
138
|
return connectorFromRow(row);
|
|
136
139
|
}
|
|
@@ -150,17 +153,17 @@ export function localImportConnectors(
|
|
|
150
153
|
const fresh =
|
|
151
154
|
mode === 'overwrite'
|
|
152
155
|
? rows
|
|
153
|
-
: rows.filter((row) => !current.some((r) => r.id === row.id
|
|
156
|
+
: rows.filter((row) => !current.some((r) => r.id === row.id));
|
|
154
157
|
const imported: LocalConnectorRow[] = fresh.map((row) => ({
|
|
155
158
|
id: row.id,
|
|
156
159
|
name: row.name,
|
|
157
160
|
url: row.url,
|
|
158
161
|
config: readConfig(row.config),
|
|
159
162
|
}));
|
|
160
|
-
for (const row of imported) assertLocalMigrationEdit(row.id,
|
|
163
|
+
for (const row of imported) assertLocalMigrationEdit(row.id, dir);
|
|
161
164
|
const ids = new Set(imported.map((r) => r.id));
|
|
162
|
-
|
|
163
|
-
writeRows(dir, [...current.filter((r) => !ids.has(r.id)
|
|
165
|
+
if (ids.size !== imported.length) throw new ConnectorError('The import contains duplicate connector IDs.', 400);
|
|
166
|
+
writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
|
|
164
167
|
return imported.length;
|
|
165
168
|
}
|
|
166
169
|
|
|
@@ -172,14 +175,7 @@ export async function localGetConnector(id: string, dir = agentsDir()): Promise<
|
|
|
172
175
|
return Promise.resolve(connectorFromRow(rowOrThrow(id, dir)));
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
function assertNameFree(name: string, exceptId: string | null, dir: string): void {
|
|
176
|
-
if (readLocalConnectors(dir).some((r) => r.id !== exceptId && r.name === name))
|
|
177
|
-
throw new ConnectorError(`a connector named '${name}' already exists on this daemon`, 409);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
178
|
function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
|
|
181
|
-
assertLocalMigrationEdit('', name, dir);
|
|
182
|
-
assertNameFree(name, null, dir);
|
|
183
179
|
const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), config };
|
|
184
180
|
writeRows(dir, [...readLocalConnectors(dir), row]);
|
|
185
181
|
return connectorFromRow(row);
|
|
@@ -193,7 +189,7 @@ export async function localCreateConnector(input: ConnectorInput, dir = agentsDi
|
|
|
193
189
|
return withLocalMigrationOperation('', dir, async (operation) => {
|
|
194
190
|
const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
|
|
195
191
|
return insert(dir, name, url, { auth, createdAt: new Date().toISOString(), verified, oauth: await oauthCapable(url, auth, operation.fetch), client });
|
|
196
|
-
}, true
|
|
192
|
+
}, true);
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
export async function localCreatePendingConnector(input: PendingConnectorInput, dir = agentsDir()): Promise<Connector> {
|
|
@@ -309,13 +305,12 @@ export async function loadConnectorPolicies(dir = agentsDir()): Promise<void> {
|
|
|
309
305
|
export async function localRenameConnector(id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
310
306
|
const name = connectorName(raw);
|
|
311
307
|
const row = rowOrThrow(id, dir);
|
|
312
|
-
assertNameFree(name, id, dir);
|
|
313
308
|
return Promise.resolve(replace(dir, { ...row, name }));
|
|
314
309
|
}
|
|
315
310
|
|
|
316
311
|
export async function localDeleteConnector(id: string, dir = agentsDir()): Promise<DeletedConnector> {
|
|
317
312
|
const row = rowOrThrow(id, dir);
|
|
318
|
-
assertLocalMigrationEdit(id,
|
|
313
|
+
assertLocalMigrationEdit(id, dir);
|
|
319
314
|
writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
|
|
320
315
|
return Promise.resolve({ id: row.id, name: row.name });
|
|
321
316
|
}
|
|
@@ -1,85 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
4
|
-
import { authHeaders, INITIALIZE, mcpPost, payloadOf, type ConnectorAuth } from '@metro-labs/http/connector-mcp';
|
|
5
|
-
import { refused } from '@metro-labs/http/connector-url';
|
|
6
|
-
|
|
7
|
-
export interface RemoteTool {
|
|
8
|
-
name: string;
|
|
9
|
-
title: string;
|
|
10
|
-
description: string;
|
|
11
|
-
readOnly: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const TIMEOUT_MS = 15_000;
|
|
15
|
-
const MAX_PAGES = 10;
|
|
16
|
-
|
|
17
|
-
function resultOf(text: string, contentType: string): Record<string, unknown> | null {
|
|
18
|
-
let parsed: unknown = null;
|
|
19
|
-
try {
|
|
20
|
-
parsed = JSON.parse(payloadOf(text, contentType));
|
|
21
|
-
} catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
return isRecord(parsed) && isRecord(parsed.result) ? parsed.result : null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>, body: unknown, fetchImpl?: FetchLike): Promise<Record<string, unknown> | null> {
|
|
28
|
-
const res = await mcpPost(url, auth, body, { timeoutMs: TIMEOUT_MS, headers: Object.fromEntries(session), fetch: fetchImpl });
|
|
29
|
-
const id = res.headers.get('mcp-session-id');
|
|
30
|
-
if (id !== null && id !== '') session.set('mcp-session-id', id);
|
|
31
|
-
const text = await res.text().catch(() => '');
|
|
32
|
-
if (res.status === 401 || res.status === 403) throw refused(`${url.hostname} rejected the credential; connect it again`);
|
|
33
|
-
if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)}`);
|
|
34
|
-
return resultOf(text, res.headers.get('content-type') ?? '');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toTool(raw: unknown): RemoteTool | null {
|
|
38
|
-
if (!isRecord(raw) || typeof raw.name !== 'string') return null;
|
|
39
|
-
const annotations = isRecord(raw.annotations) ? raw.annotations : {};
|
|
40
|
-
const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
|
|
41
|
-
return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
|
|
45
|
-
if (!session.has('mcp-session-id')) return;
|
|
46
|
-
try {
|
|
47
|
-
const send = fetchImpl ?? fetch;
|
|
48
|
-
const res = await send(url.toString(), { method: 'DELETE', redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { ...authHeaders(auth), ...Object.fromEntries(session) } });
|
|
49
|
-
if (res.ok) await res.body?.pipeTo(new WritableStream<Uint8Array>());
|
|
50
|
-
else await res.body?.cancel();
|
|
51
|
-
} catch (err) {
|
|
52
|
-
log.debug(fetchImpl === undefined ? { host: url.hostname, err: errMsg(err) } : {}, 'connector-tools: the remote declined to end the session');
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const toolsOf = (result: Record<string, unknown>): RemoteTool[] =>
|
|
57
|
-
Array.isArray(result.tools) ? result.tools.flatMap((raw: unknown) => toTool(raw) ?? []) : [];
|
|
58
|
-
|
|
59
|
-
function nextCursor(result: Record<string, unknown>, current: string): string | null {
|
|
60
|
-
const next = result.nextCursor;
|
|
61
|
-
return typeof next !== 'string' || next === '' || next === current ? null : next;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
|
|
65
|
-
const tools: RemoteTool[] = [];
|
|
66
|
-
let cursor: string | null = '';
|
|
67
|
-
for (let page = 0; page < MAX_PAGES && cursor !== null; page += 1) {
|
|
68
|
-
const result = await rpc(url, auth, session, { jsonrpc: '2.0', id: 2 + page, method: 'tools/list', params: cursor === '' ? {} : { cursor } }, fetchImpl);
|
|
69
|
-
if (result === null) break;
|
|
70
|
-
tools.push(...toolsOf(result));
|
|
71
|
-
cursor = nextCursor(result, cursor);
|
|
72
|
-
}
|
|
73
|
-
return tools;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export async function listRemoteTools(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
|
|
77
|
-
const session = new Map<string, string>();
|
|
78
|
-
const init = await rpc(url, auth, session, INITIALIZE, fetchImpl);
|
|
79
|
-
if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
|
|
80
|
-
session.set('mcp-protocol-version', init.protocolVersion);
|
|
81
|
-
await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' }, fetchImpl);
|
|
82
|
-
const tools = await walkTools(url, auth, session, fetchImpl);
|
|
83
|
-
await endSession(url, auth, session, fetchImpl);
|
|
84
|
-
return tools;
|
|
85
|
-
}
|
|
1
|
+
export { listRemoteTools, type RemoteTool } from '@metro-labs/http/connector-tools';
|
|
@@ -6,7 +6,7 @@ import { connectorAuth, connectorName } from '@metro-labs/http/connector-input';
|
|
|
6
6
|
import { readConfig } from './config.js';
|
|
7
7
|
import { parseConnectorUrl } from '@metro-labs/http/connector-url';
|
|
8
8
|
import { normalizePolicy } from '../policy/policy.js';
|
|
9
|
-
import { localImportConnectors,
|
|
9
|
+
import { localImportConnectors, type LocalConnectorRow } from './store.js';
|
|
10
10
|
|
|
11
11
|
export interface ConnectorCopyResult {
|
|
12
12
|
sourceId: string | null;
|
|
@@ -28,7 +28,6 @@ function copyRow(raw: unknown): LocalConnectorRow {
|
|
|
28
28
|
export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyResult[] {
|
|
29
29
|
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connectors) || value.connectors.length > 500)
|
|
30
30
|
throw new ApiError('invalid connector transfer', 400);
|
|
31
|
-
const names = new Set(readLocalConnectors(dir).map((row) => row.name));
|
|
32
31
|
const imported: LocalConnectorRow[] = [];
|
|
33
32
|
const results: ConnectorCopyResult[] = value.connectors.map((raw: unknown) => {
|
|
34
33
|
const sourceId = isRecord(raw) && typeof raw.id === 'string' ? parseId(raw.id) : null;
|
|
@@ -38,8 +37,6 @@ export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyRe
|
|
|
38
37
|
} catch {
|
|
39
38
|
return { sourceId, status: 'invalid' };
|
|
40
39
|
}
|
|
41
|
-
if (names.has(row.name)) return { sourceId, status: 'skipped' };
|
|
42
|
-
names.add(row.name);
|
|
43
40
|
const id = newId();
|
|
44
41
|
imported.push({ ...row, id });
|
|
45
42
|
return { sourceId, id, status: 'copied' };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { str } from '@metro-labs/core/str';
|
|
2
2
|
import { readPreview, shownValue } from '../approvals/preview.js';
|
|
3
|
-
import { connectorToolOf } from '../connectors/gates.js';
|
|
3
|
+
import { connectorGates, connectorToolOf } from '../connectors/gates.js';
|
|
4
4
|
import { stationByName } from '../stations/registry.js';
|
|
5
5
|
import { stationOfAccount } from './read-tool.js';
|
|
6
6
|
|
|
@@ -92,7 +92,11 @@ function bodyLines(params: PermissionParams): string[] {
|
|
|
92
92
|
const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
|
|
93
93
|
if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
|
|
94
94
|
const connector = connectorToolOf(params.tool_name);
|
|
95
|
-
if (connector !== undefined)
|
|
95
|
+
if (connector !== undefined) {
|
|
96
|
+
const { gate, tool } = connector;
|
|
97
|
+
const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
|
|
98
|
+
return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
|
|
99
|
+
}
|
|
96
100
|
return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -4,7 +4,6 @@ import { accountFromLine, knownAccounts } from '../agents/map.js';
|
|
|
4
4
|
import { policyFor, storedPolicy, strictest, type Access, type PolicyTarget } from '../policy/policy.js';
|
|
5
5
|
import { errResult } from './ctx.js';
|
|
6
6
|
import { NEEDS_APPROVAL, takeGrant } from '../approvals/pending.js';
|
|
7
|
-
import { connectorToolOf } from '../connectors/gates.js';
|
|
8
7
|
import { profileScopeLine } from './profile-lookup.js';
|
|
9
8
|
import { stationOfAccount } from './read-tool.js';
|
|
10
9
|
import { stationForTool, toolGroupOf } from './tool-catalog.js';
|
|
@@ -56,7 +55,7 @@ export function channelDecision(name: string, a: Record<string, unknown>): Polic
|
|
|
56
55
|
return verdict;
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
const isChannelTool = (name: string) => (tool: string): boolean => tool
|
|
58
|
+
const isChannelTool = (name: string) => (tool: string): boolean => tool === `mcp__metro__${name}`;
|
|
60
59
|
|
|
61
60
|
export function policyGate(name: string, a: Record<string, unknown>): ToolResult | undefined {
|
|
62
61
|
const { access, target } = channelDecision(name, a);
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./connector-resource": "./src/connector-resource.ts",
|
|
19
19
|
"./connector-reach": "./src/connector-reach.ts",
|
|
20
20
|
"./connector-mcp": "./src/connector-mcp.ts",
|
|
21
|
+
"./connector-tools": "./src/connector-tools.ts",
|
|
21
22
|
"./connector-discovery": "./src/connector-discovery.ts",
|
|
22
23
|
"./connector-oauth": "./src/connector-oauth.ts",
|
|
23
24
|
"./connector-input": "./src/connector-input.ts",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseId } from '@metro-labs/core/ids';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
import { connectorName } from './connector-input.js';
|
|
4
|
-
import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
|
|
4
|
+
import { connectorPolicyLayersOf, connectorPolicyOf, type ConnectorPolicy, type ConnectorPolicyLayers } from './connector-policy.js';
|
|
5
5
|
import { parsePublicConnectorUrl } from './connector-public-url.js';
|
|
6
6
|
import { isOrganizationId } from './workos-token.js';
|
|
7
7
|
|
|
@@ -23,6 +23,7 @@ export interface DeliveredConnector {
|
|
|
23
23
|
auth: 'none' | 'header' | 'oauth';
|
|
24
24
|
headers: Record<string, string>;
|
|
25
25
|
policy: ConnectorPolicy;
|
|
26
|
+
policyLayers?: ConnectorPolicyLayers;
|
|
26
27
|
revision: string;
|
|
27
28
|
state: 'ready' | 'signin' | 'unavailable';
|
|
28
29
|
expiresAt?: number;
|
|
@@ -91,7 +92,7 @@ function urlOf(value: unknown): string {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function connectorOf(raw: unknown): DeliveredConnector {
|
|
94
|
-
const value = deliveryRecord(raw, ['id', 'name', 'url', 'auth', 'headers', 'policy', 'revision', 'state', 'expiresAt']);
|
|
95
|
+
const value = deliveryRecord(raw, ['id', 'name', 'url', 'auth', 'headers', 'policy', 'policyLayers', 'revision', 'state', 'expiresAt']);
|
|
95
96
|
const id = parseId(value.id);
|
|
96
97
|
if (id === null || typeof value.name !== 'string' || connectorName(value.name) !== value.name) throw new Error('Invalid delivered connector.');
|
|
97
98
|
const url = urlOf(value.url);
|
|
@@ -99,7 +100,7 @@ function connectorOf(raw: unknown): DeliveredConnector {
|
|
|
99
100
|
const state = stateOf(value.state, auth);
|
|
100
101
|
deliveryBytes(value.revision, 32);
|
|
101
102
|
if (Object.hasOwn(value, 'expiresAt') && !timestamp(value.expiresAt)) throw new Error('Invalid delivered connector expiry.');
|
|
102
|
-
return { id, name: value.name, url, auth, state, revision: String(value.revision), headers: headersOf(value.headers, auth, state), policy: connectorPolicyOf(value.policy), ...(timestamp(value.expiresAt) ? { expiresAt: value.expiresAt } : {}) };
|
|
103
|
+
return { id, name: value.name, url, auth, state, revision: String(value.revision), headers: headersOf(value.headers, auth, state), policy: connectorPolicyOf(value.policy), ...(Object.hasOwn(value, 'policyLayers') ? { policyLayers: connectorPolicyLayersOf(value.policyLayers) } : {}), ...(timestamp(value.expiresAt) ? { expiresAt: value.expiresAt } : {}) };
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export function deliverySnapshotOf(raw: unknown): DeliverySnapshot {
|
|
@@ -2,8 +2,8 @@ import type { KeyObject } from 'node:crypto';
|
|
|
2
2
|
import { CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliveryContextBytes, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot } from './connector-delivery-validation.js';
|
|
3
3
|
import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
|
|
4
4
|
|
|
5
|
-
export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
|
|
6
|
-
export { combineConnectorPolicies, connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
|
|
5
|
+
export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
|
|
6
|
+
export { combineConnectorPolicies, connectorPolicyOf, effectiveConnectorPolicy, connectorPoliciesBlocked, validConnectorToolName, type ConnectorPolicy, type ConnectorPolicyLayers } from './connector-policy.js';
|
|
7
7
|
export type SealedDelivery = SealedTransfer;
|
|
8
8
|
|
|
9
9
|
export function sealConnectorDelivery(publicKey: string, context: DeliveryContext, snapshot: DeliverySnapshot): SealedDelivery {
|
|
@@ -2,15 +2,24 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
2
2
|
|
|
3
3
|
export type ConnectorDecision = 'allow' | 'ask' | 'deny';
|
|
4
4
|
|
|
5
|
+
type ToolGroup = 'read' | 'write';
|
|
6
|
+
|
|
5
7
|
export interface ConnectorPolicy {
|
|
6
8
|
read?: ConnectorDecision;
|
|
7
9
|
write?: ConnectorDecision;
|
|
8
10
|
tools?: Record<string, ConnectorDecision>;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
export interface ConnectorPolicyLayers {
|
|
14
|
+
organization: ConnectorPolicy;
|
|
15
|
+
agent: ConnectorPolicy;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
const RANK: Record<ConnectorDecision, number> = { allow: 0, ask: 1, deny: 2 };
|
|
12
19
|
const TOOL_RE = /^[A-Za-z0-9_.:/-]{1,256}$/;
|
|
13
20
|
|
|
21
|
+
export const validConnectorToolName = (name: string): boolean => TOOL_RE.test(name) && !['__proto__', 'constructor', 'prototype'].includes(name);
|
|
22
|
+
|
|
14
23
|
const decision = (value: unknown): value is ConnectorDecision => value === 'allow' || value === 'ask' || value === 'deny';
|
|
15
24
|
|
|
16
25
|
export function connectorPolicyOf(value: unknown): ConnectorPolicy {
|
|
@@ -30,15 +39,23 @@ function toolPolicyOf(value: unknown): Record<string, ConnectorDecision> {
|
|
|
30
39
|
if (!isRecord(value) || Object.keys(value).length > 2000) throw new Error('Invalid connector policy.');
|
|
31
40
|
const out: Record<string, ConnectorDecision> = {};
|
|
32
41
|
for (const [name, rule] of Object.entries(value)) {
|
|
33
|
-
if (!
|
|
42
|
+
if (!validConnectorToolName(name) || !decision(rule)) throw new Error('Invalid connector policy.');
|
|
34
43
|
out[name] = rule;
|
|
35
44
|
}
|
|
36
45
|
return out;
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
export function connectorPolicyLayersOf(value: unknown): ConnectorPolicyLayers {
|
|
49
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !['organization', 'agent'].includes(key))) throw new Error('Invalid connector policy layers.');
|
|
50
|
+
return { organization: connectorPolicyOf(value.organization), agent: connectorPolicyOf(value.agent) };
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
const strictest = (...values: (ConnectorDecision | undefined)[]): ConnectorDecision => values.reduce<ConnectorDecision>((held, next) => next !== undefined && RANK[next] > RANK[held] ? next : held, 'allow');
|
|
54
|
+
const toolRule = (policy: ConnectorPolicy, name: string): ConnectorDecision | undefined => policy.tools !== undefined && Object.hasOwn(policy.tools, name) ? policy.tools[name] : undefined;
|
|
55
|
+
const toolGroup = (groups: Record<string, ToolGroup>, name: string): ToolGroup => Object.hasOwn(groups, name) && groups[name] === 'read' ? 'read' : 'write';
|
|
56
|
+
const access = (policy: ConnectorPolicy, name: string, group: ToolGroup): ConnectorDecision => toolRule(policy, name) ?? policy[group] ?? 'allow';
|
|
40
57
|
|
|
41
|
-
export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy): ConnectorPolicy {
|
|
58
|
+
export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy, groups?: Record<string, ToolGroup>): ConnectorPolicy {
|
|
42
59
|
const policies = [connectorPolicyOf(global), connectorPolicyOf(assignment)];
|
|
43
60
|
const neutral = (policy: ConnectorPolicy): boolean => (policy.read ?? 'allow') === 'allow' && (policy.write ?? 'allow') === 'allow' && Object.keys(policy.tools ?? {}).length === 0;
|
|
44
61
|
if (neutral(assignment)) return connectorPolicyOf(global);
|
|
@@ -48,9 +65,28 @@ export function combineConnectorPolicies(global: ConnectorPolicy, assignment: Co
|
|
|
48
65
|
write: strictest(...policies.map((policy) => policy.write)),
|
|
49
66
|
};
|
|
50
67
|
const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
|
|
68
|
+
if (groups === undefined && names.size > 2000) return { read: 'deny', write: 'deny' };
|
|
51
69
|
if (names.size > 0) {
|
|
52
70
|
out.tools = {};
|
|
53
|
-
for (const name of names)
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
out.tools[name] = groups === undefined
|
|
73
|
+
? strictest(...policies.flatMap((policy) => [policy.read, policy.write, toolRule(policy, name)]))
|
|
74
|
+
: strictest(...policies.map((policy) => access(policy, name, toolGroup(groups, name))));
|
|
75
|
+
}
|
|
54
76
|
}
|
|
55
77
|
return out;
|
|
56
78
|
}
|
|
79
|
+
|
|
80
|
+
export function effectiveConnectorPolicy(row: { policy: ConnectorPolicy; policyLayers?: ConnectorPolicyLayers }, groups: Record<string, ToolGroup>): ConnectorPolicy {
|
|
81
|
+
const layers = row.policyLayers;
|
|
82
|
+
return layers === undefined ? connectorPolicyOf(row.policy) : combineConnectorPolicies(layers.organization, layers.agent, groups);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function connectorPoliciesBlocked(layers: ConnectorPolicyLayers): boolean {
|
|
86
|
+
const policies = [layers.organization, layers.agent];
|
|
87
|
+
const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
|
|
88
|
+
return (['read', 'write'] as const).every((group) =>
|
|
89
|
+
strictest(...policies.map((policy) => policy[group])) === 'deny' &&
|
|
90
|
+
[...names].every((name) => strictest(...policies.map((policy) => access(policy, name, group))) === 'deny'),
|
|
91
|
+
);
|
|
92
|
+
}
|