@fungi.computer/veil 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/connections/contract.d.ts +141 -0
- package/dist/connections/contract.js +55 -0
- package/dist/connections/core.d.ts +48 -0
- package/dist/connections/core.js +372 -0
- package/dist/connections/egress.d.ts +8 -0
- package/dist/connections/egress.js +77 -0
- package/dist/connections/index.d.ts +3 -0
- package/dist/connections/index.js +3 -0
- package/dist/connections/sealed.d.ts +21 -0
- package/dist/connections/sealed.js +54 -0
- package/dist/credential-store.d.ts +64 -0
- package/dist/credential-store.js +151 -0
- package/dist/index.d.ts +1 -1
- package/dist/model-credentials.d.ts +8 -7
- package/dist/model-credentials.js +15 -12
- package/dist/model-keys.d.ts +24 -0
- package/dist/openapi-secrets.d.ts +1 -1
- package/dist/openapi-secrets.js +19 -58
- package/dist/provider-egress.d.ts +16 -5
- package/dist/provider-egress.js +194 -4
- package/package.json +12 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { parseOpenApiTable } from "@fungi.computer/forage/openapi";
|
|
4
|
+
import { assertPublicHostName } from "../model-key-network.js";
|
|
5
|
+
import { createPublicEgressPolicy } from "../public-egress-policy.js";
|
|
6
|
+
import { ConnectionsError, credentialSchema, draftSchema, egressRefSchema, grantInputSchema, identifier, toolsSchema, } from "./contract.js";
|
|
7
|
+
import { admitRequest } from "./egress.js";
|
|
8
|
+
const attemptTtlMs = 10 * 60 * 1000;
|
|
9
|
+
const cleanupLimit = 5;
|
|
10
|
+
const json = (schema) => Schema.fromJsonString(schema);
|
|
11
|
+
const connectionRow = Schema.Struct({
|
|
12
|
+
id: identifier,
|
|
13
|
+
name: Schema.String,
|
|
14
|
+
surface: json(Schema.Struct({
|
|
15
|
+
kind: Schema.Literals(["mcp", "openapi"]),
|
|
16
|
+
url: Schema.String,
|
|
17
|
+
table: Schema.NullOr(Schema.Unknown),
|
|
18
|
+
})),
|
|
19
|
+
credential: json(credentialSchema),
|
|
20
|
+
locator: Schema.NullOr(Schema.String),
|
|
21
|
+
state: Schema.Literals(["active", "reconnect_required", "revoked"]),
|
|
22
|
+
authorityVersion: Schema.Number,
|
|
23
|
+
});
|
|
24
|
+
const grantRow = Schema.Struct({
|
|
25
|
+
connectionId: identifier,
|
|
26
|
+
agentId: identifier,
|
|
27
|
+
tools: json(toolsSchema),
|
|
28
|
+
grantor: identifier,
|
|
29
|
+
version: Schema.Number,
|
|
30
|
+
active: Schema.Literals([0, 1]),
|
|
31
|
+
});
|
|
32
|
+
const receiptRow = Schema.Struct({
|
|
33
|
+
fingerprint: Schema.String,
|
|
34
|
+
result: json(Schema.Struct({ connectionId: identifier })),
|
|
35
|
+
});
|
|
36
|
+
const cleanupRow = Schema.Struct({
|
|
37
|
+
id: Schema.Number,
|
|
38
|
+
connectionId: identifier,
|
|
39
|
+
locator: Schema.String,
|
|
40
|
+
credential: json(credentialSchema),
|
|
41
|
+
attempts: Schema.Number,
|
|
42
|
+
});
|
|
43
|
+
const count = Schema.Struct({ n: Schema.Number });
|
|
44
|
+
function decode(schema, input) {
|
|
45
|
+
try {
|
|
46
|
+
return Schema.decodeUnknownSync(schema)(input);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new ConnectionsError("invalid_input");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const sync = (operation) => Effect.try({
|
|
53
|
+
try: operation,
|
|
54
|
+
catch: (cause) => cause instanceof ConnectionsError
|
|
55
|
+
? cause
|
|
56
|
+
: new ConnectionsError("unavailable", { cause }),
|
|
57
|
+
});
|
|
58
|
+
const io = (operation) => Effect.tryPromise({
|
|
59
|
+
try: operation,
|
|
60
|
+
catch: (cause) => cause instanceof ConnectionsError
|
|
61
|
+
? cause
|
|
62
|
+
: new ConnectionsError("unavailable", { cause }),
|
|
63
|
+
});
|
|
64
|
+
/** One owner's connections: attempts, connections, grants, receipts and cleanup in its SQLite. */
|
|
65
|
+
export function createConnections(options) {
|
|
66
|
+
const ownerId = decode(identifier, options.ownerId);
|
|
67
|
+
const { sql, transactionSync } = options.store;
|
|
68
|
+
const policy = createPublicEgressPolicy(options.egressPolicy);
|
|
69
|
+
sql.exec(`
|
|
70
|
+
CREATE TABLE IF NOT EXISTS veil_connection_attempts (
|
|
71
|
+
state TEXT PRIMARY KEY, connectionId TEXT NOT NULL, actor TEXT NOT NULL,
|
|
72
|
+
status TEXT NOT NULL, expiresAt INTEGER NOT NULL,
|
|
73
|
+
pkceVerifierSealed TEXT, issuer TEXT) STRICT;
|
|
74
|
+
CREATE TABLE IF NOT EXISTS veil_connections (
|
|
75
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, surface TEXT NOT NULL, credential TEXT NOT NULL,
|
|
76
|
+
locator TEXT, state TEXT NOT NULL, authorityVersion INTEGER NOT NULL) STRICT;
|
|
77
|
+
CREATE TABLE IF NOT EXISTS veil_connection_grants (
|
|
78
|
+
connectionId TEXT NOT NULL, agentId TEXT NOT NULL, tools TEXT NOT NULL,
|
|
79
|
+
grantor TEXT NOT NULL, version INTEGER NOT NULL, active INTEGER NOT NULL,
|
|
80
|
+
PRIMARY KEY (connectionId, agentId)) STRICT;
|
|
81
|
+
CREATE TABLE IF NOT EXISTS veil_connection_receipts (
|
|
82
|
+
requestId TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL) STRICT;
|
|
83
|
+
CREATE TABLE IF NOT EXISTS veil_connection_cleanup (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, connectionId TEXT NOT NULL, locator TEXT NOT NULL,
|
|
85
|
+
credential TEXT NOT NULL, attempts INTEGER NOT NULL) STRICT;
|
|
86
|
+
CREATE TABLE IF NOT EXISTS veil_connection_epoch (id INTEGER PRIMARY KEY CHECK (id = 1), revision INTEGER NOT NULL) STRICT;
|
|
87
|
+
INSERT OR IGNORE INTO veil_connection_epoch VALUES (1, 1);`);
|
|
88
|
+
const rows = (schema, statement, ...bindings) => sql
|
|
89
|
+
.exec(statement, ...bindings)
|
|
90
|
+
.toArray()
|
|
91
|
+
.map((row) => decode(schema, row));
|
|
92
|
+
const bump = () => sql.exec("UPDATE veil_connection_epoch SET revision = revision + 1");
|
|
93
|
+
const authorize = (actor, purpose) => {
|
|
94
|
+
decode(identifier, actor);
|
|
95
|
+
if (!options.authorize(actor, purpose))
|
|
96
|
+
throw new ConnectionsError("denied");
|
|
97
|
+
};
|
|
98
|
+
const connection = (id) => {
|
|
99
|
+
const row = rows(connectionRow, "SELECT * FROM veil_connections WHERE id = ?", id)[0];
|
|
100
|
+
if (!row)
|
|
101
|
+
throw new ConnectionsError("denied");
|
|
102
|
+
return row;
|
|
103
|
+
};
|
|
104
|
+
const grant = (id, agentId) => rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND agentId = ?", id, agentId)[0];
|
|
105
|
+
const address = (row) => ({
|
|
106
|
+
ownerId,
|
|
107
|
+
connectionId: row.id,
|
|
108
|
+
credential: row.credential,
|
|
109
|
+
});
|
|
110
|
+
/** The surface URL must be public https with nothing the owner did not choose to send. */
|
|
111
|
+
const publicUrl = (value) => {
|
|
112
|
+
if (!URL.canParse(value))
|
|
113
|
+
throw new ConnectionsError("invalid_input");
|
|
114
|
+
const url = new URL(value);
|
|
115
|
+
if (url.protocol !== "https:" ||
|
|
116
|
+
url.username ||
|
|
117
|
+
url.password ||
|
|
118
|
+
url.search ||
|
|
119
|
+
url.hash)
|
|
120
|
+
throw new ConnectionsError("invalid_input");
|
|
121
|
+
try {
|
|
122
|
+
assertPublicHostName(url.hostname);
|
|
123
|
+
policy.destination(url);
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
throw new ConnectionsError("denied", { cause });
|
|
127
|
+
}
|
|
128
|
+
return url.href;
|
|
129
|
+
};
|
|
130
|
+
const sweep = () => Effect.gen(function* () {
|
|
131
|
+
const due = yield* sync(() => rows(cleanupRow, "SELECT * FROM veil_connection_cleanup WHERE attempts < ?", cleanupLimit));
|
|
132
|
+
for (const job of due) {
|
|
133
|
+
const outcome = yield* options.custody
|
|
134
|
+
.remove({
|
|
135
|
+
ownerId,
|
|
136
|
+
connectionId: job.connectionId,
|
|
137
|
+
credential: job.credential,
|
|
138
|
+
}, job.locator)
|
|
139
|
+
.pipe(Effect.result);
|
|
140
|
+
yield* sync(() => {
|
|
141
|
+
if (outcome._tag === "Success")
|
|
142
|
+
sql.exec("DELETE FROM veil_connection_cleanup WHERE id = ?", job.id);
|
|
143
|
+
else
|
|
144
|
+
sql.exec("UPDATE veil_connection_cleanup SET attempts = attempts + 1 WHERE id = ?", job.id);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const enqueueCleanup = (row) => {
|
|
149
|
+
if (row.locator !== null)
|
|
150
|
+
sql.exec("INSERT INTO veil_connection_cleanup (connectionId, locator, credential, attempts) VALUES (?, ?, ?, 0)", row.id, row.locator, JSON.stringify(row.credential));
|
|
151
|
+
};
|
|
152
|
+
const fingerprint = (draft) => JSON.stringify([
|
|
153
|
+
draft.name,
|
|
154
|
+
draft.surface.kind,
|
|
155
|
+
"url" in draft.surface ? draft.surface.url : draft.surface.baseUrl,
|
|
156
|
+
draft.credential,
|
|
157
|
+
draft.secret !== undefined,
|
|
158
|
+
]);
|
|
159
|
+
const begin = (actor, input) => Effect.gen(function* () {
|
|
160
|
+
const draft = yield* sync(() => {
|
|
161
|
+
authorize(actor, "manage");
|
|
162
|
+
const parsed = decode(draftSchema, input);
|
|
163
|
+
if (parsed.credential.kind === "static"
|
|
164
|
+
? parsed.secret === undefined
|
|
165
|
+
: parsed.secret !== undefined)
|
|
166
|
+
throw new ConnectionsError("invalid_input");
|
|
167
|
+
return parsed;
|
|
168
|
+
});
|
|
169
|
+
const replay = yield* sync(() => {
|
|
170
|
+
const receipt = rows(receiptRow, "SELECT fingerprint, result FROM veil_connection_receipts WHERE requestId = ?", draft.requestId)[0];
|
|
171
|
+
if (!receipt)
|
|
172
|
+
return null;
|
|
173
|
+
if (receipt.fingerprint !== fingerprint(draft))
|
|
174
|
+
throw new ConnectionsError("conflict");
|
|
175
|
+
return receipt.result.connectionId;
|
|
176
|
+
});
|
|
177
|
+
if (replay !== null)
|
|
178
|
+
return { connectionId: replay, authorizeUrl: null };
|
|
179
|
+
const endpoint = yield* sync(() => publicUrl(draft.surface.kind === "mcp"
|
|
180
|
+
? draft.surface.url
|
|
181
|
+
: draft.surface.baseUrl));
|
|
182
|
+
const surface = draft.surface;
|
|
183
|
+
const table = surface.kind === "openapi"
|
|
184
|
+
? (yield* io(() => parseOpenApiTable(surface.table))).operations
|
|
185
|
+
: null;
|
|
186
|
+
const connectionId = options.ids();
|
|
187
|
+
const state = options.ids();
|
|
188
|
+
yield* sync(() => {
|
|
189
|
+
decode(identifier, connectionId);
|
|
190
|
+
sql.exec("INSERT INTO veil_connection_attempts (state, connectionId, actor, status, expiresAt) VALUES (?, ?, ?, 'pending', ?)", state, connectionId, actor, options.clock() + attemptTtlMs);
|
|
191
|
+
});
|
|
192
|
+
const addr = { ownerId, connectionId, credential: draft.credential };
|
|
193
|
+
const locator = draft.secret === undefined
|
|
194
|
+
? null
|
|
195
|
+
: yield* options.custody.put(addr, draft.secret);
|
|
196
|
+
const stored = {
|
|
197
|
+
id: connectionId,
|
|
198
|
+
name: draft.name,
|
|
199
|
+
credential: draft.credential,
|
|
200
|
+
locator,
|
|
201
|
+
state: "active",
|
|
202
|
+
authorityVersion: 1,
|
|
203
|
+
surface: {
|
|
204
|
+
kind: draft.surface.kind,
|
|
205
|
+
url: endpoint,
|
|
206
|
+
table,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
return yield* sync(() => transactionSync(() => {
|
|
210
|
+
authorize(actor, "manage");
|
|
211
|
+
const attempt = rows(count, "SELECT COUNT(*) AS n FROM veil_connection_attempts WHERE state = ? AND status = 'pending' AND expiresAt > ?", state, options.clock())[0];
|
|
212
|
+
if (attempt?.n !== 1)
|
|
213
|
+
throw new ConnectionsError("denied");
|
|
214
|
+
sql.exec("INSERT INTO veil_connections VALUES (?, ?, ?, ?, ?, 'active', 1)", stored.id, stored.name, JSON.stringify(stored.surface), JSON.stringify(stored.credential), stored.locator);
|
|
215
|
+
sql.exec("UPDATE veil_connection_attempts SET status = 'completed' WHERE state = ?", state);
|
|
216
|
+
sql.exec("INSERT INTO veil_connection_receipts VALUES (?, ?, ?)", draft.requestId, fingerprint(draft), JSON.stringify({ connectionId }));
|
|
217
|
+
bump();
|
|
218
|
+
return { connectionId, authorizeUrl: null };
|
|
219
|
+
})).pipe(Effect.tapError(() => sync(() => {
|
|
220
|
+
enqueueCleanup(stored);
|
|
221
|
+
})));
|
|
222
|
+
});
|
|
223
|
+
const grantTo = (actor, input) => sync(() => transactionSync(() => {
|
|
224
|
+
authorize(actor, "manage");
|
|
225
|
+
const wanted = decode(grantInputSchema, input);
|
|
226
|
+
const row = connection(wanted.connectionId);
|
|
227
|
+
if (row.state === "revoked")
|
|
228
|
+
throw new ConnectionsError("revoked");
|
|
229
|
+
if (wanted.tools === "all"
|
|
230
|
+
? false
|
|
231
|
+
: row.surface.kind !== "mcp" && wanted.tools.length === 0)
|
|
232
|
+
throw new ConnectionsError("invalid_input");
|
|
233
|
+
const tools = JSON.stringify(wanted.tools);
|
|
234
|
+
const existing = grant(wanted.connectionId, wanted.agentId);
|
|
235
|
+
if (existing?.active === 1 && JSON.stringify(existing.tools) === tools)
|
|
236
|
+
return { grantVersion: existing.version };
|
|
237
|
+
const next = (existing?.version ?? 0) + 1;
|
|
238
|
+
sql.exec(`INSERT INTO veil_connection_grants VALUES (?, ?, ?, ?, ?, 1)
|
|
239
|
+
ON CONFLICT (connectionId, agentId) DO UPDATE SET tools = excluded.tools, grantor = excluded.grantor, version = excluded.version, active = 1`, wanted.connectionId, wanted.agentId, tools, actor, next);
|
|
240
|
+
bump();
|
|
241
|
+
return { grantVersion: next };
|
|
242
|
+
}));
|
|
243
|
+
const ungrant = (actor, input) => sync(() => {
|
|
244
|
+
transactionSync(() => {
|
|
245
|
+
authorize(actor, "manage");
|
|
246
|
+
const wanted = decode(Schema.Struct({ connectionId: identifier, agentId: identifier }), input);
|
|
247
|
+
const existing = grant(wanted.connectionId, wanted.agentId);
|
|
248
|
+
if (existing?.active !== 1)
|
|
249
|
+
return;
|
|
250
|
+
sql.exec("UPDATE veil_connection_grants SET active = 0, version = version + 1 WHERE connectionId = ? AND agentId = ?", wanted.connectionId, wanted.agentId);
|
|
251
|
+
bump();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
const revoke = (actor, input) => Effect.gen(function* () {
|
|
255
|
+
yield* sync(() => {
|
|
256
|
+
transactionSync(() => {
|
|
257
|
+
authorize(actor, "manage");
|
|
258
|
+
const row = connection(decode(identifier, input.connectionId));
|
|
259
|
+
if (row.state === "revoked")
|
|
260
|
+
return;
|
|
261
|
+
sql.exec("UPDATE veil_connections SET state = 'revoked', locator = NULL, authorityVersion = authorityVersion + 1 WHERE id = ?", row.id);
|
|
262
|
+
sql.exec("UPDATE veil_connection_grants SET active = 0, version = version + 1 WHERE connectionId = ? AND active = 1", row.id);
|
|
263
|
+
enqueueCleanup(row);
|
|
264
|
+
bump();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
yield* sweep();
|
|
268
|
+
});
|
|
269
|
+
const projection = (agentId) => Effect.gen(function* () {
|
|
270
|
+
const stored = yield* sync(() => {
|
|
271
|
+
decode(identifier, agentId);
|
|
272
|
+
const revision = rows(Schema.Struct({ revision: Schema.Number }), "SELECT revision FROM veil_connection_epoch")[0]?.revision ?? 1;
|
|
273
|
+
const granted = rows(grantRow, "SELECT * FROM veil_connection_grants WHERE agentId = ? AND active = 1", agentId);
|
|
274
|
+
return {
|
|
275
|
+
revision,
|
|
276
|
+
granted: granted
|
|
277
|
+
.map((g) => ({ g, row: connection(g.connectionId) }))
|
|
278
|
+
.filter(({ row }) => row.state === "active"),
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
const connections = [];
|
|
282
|
+
for (const { g, row } of stored.granted) {
|
|
283
|
+
const surface = row.surface.kind === "mcp"
|
|
284
|
+
? { kind: "mcp", url: row.surface.url }
|
|
285
|
+
: {
|
|
286
|
+
kind: "openapi",
|
|
287
|
+
baseUrl: row.surface.url,
|
|
288
|
+
table: (yield* io(() => parseOpenApiTable(row.surface.table)))
|
|
289
|
+
.operations,
|
|
290
|
+
};
|
|
291
|
+
connections.push({
|
|
292
|
+
connectionId: row.id,
|
|
293
|
+
name: row.name,
|
|
294
|
+
surface,
|
|
295
|
+
tools: g.tools,
|
|
296
|
+
authorityVersion: row.authorityVersion,
|
|
297
|
+
grantVersion: g.version,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
revision: stored.revision,
|
|
302
|
+
connections,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
const list = (actor) => sync(() => {
|
|
306
|
+
authorize(actor, "manage");
|
|
307
|
+
return rows(connectionRow, "SELECT * FROM veil_connections ORDER BY id").map((row) => ({
|
|
308
|
+
connectionId: row.id,
|
|
309
|
+
name: row.name,
|
|
310
|
+
surface: {
|
|
311
|
+
kind: row.surface.kind,
|
|
312
|
+
origin: new URL(row.surface.url).origin,
|
|
313
|
+
},
|
|
314
|
+
credentialKind: row.credential.kind,
|
|
315
|
+
state: row.state,
|
|
316
|
+
authorityVersion: row.authorityVersion,
|
|
317
|
+
grants: rows(grantRow, "SELECT * FROM veil_connection_grants WHERE connectionId = ? AND active = 1", row.id).map((g) => ({
|
|
318
|
+
agentId: g.agentId,
|
|
319
|
+
tools: g.tools,
|
|
320
|
+
version: g.version,
|
|
321
|
+
})),
|
|
322
|
+
}));
|
|
323
|
+
});
|
|
324
|
+
/** The grant, the connection and the grantor must all still stand. */
|
|
325
|
+
const admitted = (agentId, ref) => {
|
|
326
|
+
decode(identifier, agentId);
|
|
327
|
+
const row = connection(ref.connectionId);
|
|
328
|
+
const held = grant(ref.connectionId, agentId);
|
|
329
|
+
if (row.state === "revoked" ||
|
|
330
|
+
held?.active !== 1 ||
|
|
331
|
+
held.version !== ref.grantVersion)
|
|
332
|
+
throw new ConnectionsError("revoked");
|
|
333
|
+
if (row.state === "reconnect_required")
|
|
334
|
+
throw new ConnectionsError("reconnect_required");
|
|
335
|
+
authorize(held.grantor, "grantor");
|
|
336
|
+
return { row, tools: held.tools };
|
|
337
|
+
};
|
|
338
|
+
const egress = (agentId, input) => {
|
|
339
|
+
const ref = decode(egressRefSchema, input);
|
|
340
|
+
return (target, init) => Effect.runPromise(Effect.gen(function* () {
|
|
341
|
+
const { row, tools } = yield* sync(() => admitted(agentId, ref));
|
|
342
|
+
const request = yield* io(() => admitRequest({ kind: row.surface.kind, url: row.surface.url }, tools, new Request(target, init), policy));
|
|
343
|
+
if (row.locator === null && row.credential.kind === "static")
|
|
344
|
+
return yield* Effect.fail(new ConnectionsError("denied"));
|
|
345
|
+
const response = yield* options.custody
|
|
346
|
+
.inject(address(row), row.locator ?? "", request)
|
|
347
|
+
.pipe(Effect.tapError((error) => error.code === "reconnect_required"
|
|
348
|
+
? sync(() => sql.exec("UPDATE veil_connections SET state = 'reconnect_required', authorityVersion = authorityVersion + 1 WHERE id = ? AND state = 'active'", row.id))
|
|
349
|
+
: Effect.void));
|
|
350
|
+
const stillCurrent = yield* sync(() => admitted(agentId, ref)).pipe(Effect.result);
|
|
351
|
+
if (stillCurrent._tag === "Failure" ||
|
|
352
|
+
response.redirected ||
|
|
353
|
+
(response.status >= 300 && response.status < 400)) {
|
|
354
|
+
yield* io(() => response.body?.cancel() ?? Promise.resolve());
|
|
355
|
+
return yield* Effect.fail(stillCurrent._tag === "Failure"
|
|
356
|
+
? stillCurrent.failure
|
|
357
|
+
: new ConnectionsError("invalid_response"));
|
|
358
|
+
}
|
|
359
|
+
return response;
|
|
360
|
+
}));
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
begin,
|
|
364
|
+
grant: grantTo,
|
|
365
|
+
ungrant,
|
|
366
|
+
revoke,
|
|
367
|
+
projection,
|
|
368
|
+
list,
|
|
369
|
+
egress,
|
|
370
|
+
sweep,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { createPublicEgressPolicy } from "../public-egress-policy.js";
|
|
2
|
+
import { type Tools } from "./contract.js";
|
|
3
|
+
export type Endpoint = Readonly<{
|
|
4
|
+
kind: "mcp" | "openapi";
|
|
5
|
+
url: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** One request, one verdict. The caller holds the grant; this proves the request stays inside it. */
|
|
8
|
+
export declare function admitRequest(endpoint: Endpoint, tools: Tools, request: Request, policy: ReturnType<typeof createPublicEgressPolicy>): Promise<Request>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { isJSONRPCRequest, parseJSONRPCMessage, } from "@modelcontextprotocol/client";
|
|
3
|
+
import { assertPublicHostName } from "../model-key-network.js";
|
|
4
|
+
import { ConnectionsError } from "./contract.js";
|
|
5
|
+
const bodyLimit = 256 * 1024;
|
|
6
|
+
const owned = ["authorization", "proxy-authorization", "cookie"];
|
|
7
|
+
const jsonValue = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
|
|
8
|
+
const toolCall = Schema.decodeUnknownSync(Schema.Struct({ name: Schema.String }));
|
|
9
|
+
/** One request, one verdict. The caller holds the grant; this proves the request stays inside it. */
|
|
10
|
+
export async function admitRequest(endpoint, tools, request, policy) {
|
|
11
|
+
const target = new URL(request.url);
|
|
12
|
+
const base = new URL(endpoint.url);
|
|
13
|
+
const inside = endpoint.kind === "mcp"
|
|
14
|
+
? target.href === base.href
|
|
15
|
+
: target.origin === base.origin &&
|
|
16
|
+
target.pathname.startsWith(base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`);
|
|
17
|
+
if (!inside ||
|
|
18
|
+
request.redirect !== "error" ||
|
|
19
|
+
target.username ||
|
|
20
|
+
target.password ||
|
|
21
|
+
owned.some((name) => request.headers.has(name)))
|
|
22
|
+
throw new ConnectionsError("denied");
|
|
23
|
+
try {
|
|
24
|
+
assertPublicHostName(target.hostname);
|
|
25
|
+
policy.destination(target);
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
throw new ConnectionsError("denied", { cause });
|
|
29
|
+
}
|
|
30
|
+
if (request.body === null)
|
|
31
|
+
return request;
|
|
32
|
+
const bytes = await boundedBytes(request.clone());
|
|
33
|
+
if (endpoint.kind === "mcp" && tools !== "all") {
|
|
34
|
+
let name = null;
|
|
35
|
+
try {
|
|
36
|
+
const message = parseJSONRPCMessage(jsonValue(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
37
|
+
if (isJSONRPCRequest(message) && message.method === "tools/call")
|
|
38
|
+
name = toolCall(message.params).name;
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throw new ConnectionsError("denied", { cause });
|
|
42
|
+
}
|
|
43
|
+
if (name !== null && !tools.includes(name))
|
|
44
|
+
throw new ConnectionsError("denied");
|
|
45
|
+
}
|
|
46
|
+
return new Request(request.url, {
|
|
47
|
+
method: request.method,
|
|
48
|
+
headers: request.headers,
|
|
49
|
+
body: bytes,
|
|
50
|
+
redirect: request.redirect,
|
|
51
|
+
signal: request.signal,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Read the body once, stopping as soon as it passes the byte cap. */
|
|
55
|
+
async function boundedBytes(request) {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let size = 0;
|
|
58
|
+
const reader = request.body?.getReader();
|
|
59
|
+
for (;;) {
|
|
60
|
+
const next = await reader?.read();
|
|
61
|
+
if (next === undefined || next.done)
|
|
62
|
+
break;
|
|
63
|
+
size += next.value.byteLength;
|
|
64
|
+
if (size > bodyLimit) {
|
|
65
|
+
await reader?.cancel();
|
|
66
|
+
throw new ConnectionsError("too_large");
|
|
67
|
+
}
|
|
68
|
+
chunks.push(next.value);
|
|
69
|
+
}
|
|
70
|
+
const bytes = new Uint8Array(size);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const chunk of chunks) {
|
|
73
|
+
bytes.set(chunk, offset);
|
|
74
|
+
offset += chunk.byteLength;
|
|
75
|
+
}
|
|
76
|
+
return bytes;
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CustodyAdapter } from "./contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* The slice of Veil's one credential store (`createCredentialStore`) that sealed
|
|
4
|
+
* custody needs: ciphertext at rest, the secret released only into a callback.
|
|
5
|
+
*/
|
|
6
|
+
export type CredentialVault = Readonly<{
|
|
7
|
+
put: (input: {
|
|
8
|
+
kind: string;
|
|
9
|
+
label: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
credentialId: string;
|
|
13
|
+
}>;
|
|
14
|
+
withSecret: <T>(credentialId: string, action: (secret: string) => Promise<T> | T) => Promise<T>;
|
|
15
|
+
remove: (credentialId: string) => Promise<"deleted" | "absent">;
|
|
16
|
+
}>;
|
|
17
|
+
/** Default custody: a thin adapter over the shared credential store. `send` is the checked public transport. */
|
|
18
|
+
export declare function createSealedCustody(options: {
|
|
19
|
+
vault: CredentialVault;
|
|
20
|
+
send: (request: Request) => Promise<Response>;
|
|
21
|
+
}): CustodyAdapter;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ConnectionsError, } from "./contract.js";
|
|
4
|
+
/** Where a static secret rides on the final request. */
|
|
5
|
+
function applyPlacement(request, placement, secret) {
|
|
6
|
+
if (placement !== "bearer" && "query" in placement) {
|
|
7
|
+
const target = new URL(request.url);
|
|
8
|
+
target.searchParams.set(placement.query, secret);
|
|
9
|
+
return new Request(target.href, request);
|
|
10
|
+
}
|
|
11
|
+
const headers = new Headers(request.headers);
|
|
12
|
+
if (placement === "bearer")
|
|
13
|
+
headers.set("authorization", `Bearer ${secret}`);
|
|
14
|
+
else
|
|
15
|
+
headers.set(placement.header, secret);
|
|
16
|
+
return new Request(request, { headers });
|
|
17
|
+
}
|
|
18
|
+
const failure = Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }));
|
|
19
|
+
const refusal = (cause) => {
|
|
20
|
+
if (cause instanceof ConnectionsError)
|
|
21
|
+
return cause;
|
|
22
|
+
try {
|
|
23
|
+
const { code } = failure(cause);
|
|
24
|
+
return new ConnectionsError(code === "absent" || code === "denied"
|
|
25
|
+
? "reconnect_required"
|
|
26
|
+
: "unavailable", { cause });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return new ConnectionsError("unavailable", { cause });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Default custody: a thin adapter over the shared credential store. `send` is the checked public transport. */
|
|
33
|
+
export function createSealedCustody(options) {
|
|
34
|
+
return {
|
|
35
|
+
put: (address, secret) => Effect.tryPromise({
|
|
36
|
+
try: async () => (await options.vault.put({
|
|
37
|
+
kind: "connection",
|
|
38
|
+
label: address.connectionId,
|
|
39
|
+
secret,
|
|
40
|
+
})).credentialId,
|
|
41
|
+
catch: refusal,
|
|
42
|
+
}),
|
|
43
|
+
inject: (address, locator, request) => Effect.tryPromise({
|
|
44
|
+
try: () => options.vault.withSecret(locator, (secret) => options.send(address.credential.kind === "static"
|
|
45
|
+
? applyPlacement(request, address.credential.placement, secret)
|
|
46
|
+
: request)),
|
|
47
|
+
catch: refusal,
|
|
48
|
+
}),
|
|
49
|
+
remove: (_address, locator) => Effect.tryPromise({
|
|
50
|
+
try: () => options.vault.remove(locator),
|
|
51
|
+
catch: refusal,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veil's one encrypted credential store. A host holds a dedicated 32-byte
|
|
3
|
+
* master key as a host secret; credentials (model API keys, connector tokens)
|
|
4
|
+
* are sealed under it with WebCrypto AES-GCM, bound to an owner address, and
|
|
5
|
+
* leave the store only into a callback. Listings carry no secret.
|
|
6
|
+
*/
|
|
7
|
+
/** Stable, secret-free reason a credential could not be sealed, opened or stored. */
|
|
8
|
+
export declare class CredentialError extends Error {
|
|
9
|
+
readonly code: "unavailable" | "denied" | "absent" | "limit";
|
|
10
|
+
constructor(code: "unavailable" | "denied" | "absent" | "limit");
|
|
11
|
+
}
|
|
12
|
+
/** Dedicated 32-byte base64url keys; the active key seals, retained keys open. */
|
|
13
|
+
export type CredentialKeyring = Readonly<{
|
|
14
|
+
activeKeyId: string;
|
|
15
|
+
keys: Readonly<Record<string, string>>;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* WebCrypto owns authenticated encryption. `domain` and `context` are the
|
|
19
|
+
* additional authenticated data: a sealed value opens only for its own owner.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createCredentialSealer(configuration: CredentialKeyring, domain: string): {
|
|
22
|
+
seal(context: readonly (string | null)[], credential: string): Promise<string>;
|
|
23
|
+
open(context: readonly (string | null)[], sealed: string): Promise<string>;
|
|
24
|
+
};
|
|
25
|
+
export type CredentialSealer = ReturnType<typeof createCredentialSealer>;
|
|
26
|
+
/** What a listing shows: no secret, no ciphertext. */
|
|
27
|
+
export type CredentialRecord = Readonly<{
|
|
28
|
+
credentialId: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
label: string;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
validatedAt: number;
|
|
33
|
+
}>;
|
|
34
|
+
/** One stored row: the listing fields plus the sealed envelope. */
|
|
35
|
+
export type StoredCredential = CredentialRecord & Readonly<{
|
|
36
|
+
sealed: string;
|
|
37
|
+
}>;
|
|
38
|
+
/** Durable Object style key-value storage; the store owns the `credential:` prefix. */
|
|
39
|
+
export type CredentialStorage = Readonly<{
|
|
40
|
+
get(key: string): Promise<StoredCredential | undefined>;
|
|
41
|
+
put(key: string, value: StoredCredential): Promise<void>;
|
|
42
|
+
delete(key: string): Promise<boolean>;
|
|
43
|
+
list(prefix: string): Promise<Map<string, StoredCredential>>;
|
|
44
|
+
}>;
|
|
45
|
+
export declare function createCredentialStore(options: {
|
|
46
|
+
storage: CredentialStorage;
|
|
47
|
+
sealer: CredentialSealer;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
/** Most credentials one store holds. */
|
|
50
|
+
limit?: number;
|
|
51
|
+
}): {
|
|
52
|
+
/** Seal one credential bound to its id and kind. */
|
|
53
|
+
put(input: {
|
|
54
|
+
kind: string;
|
|
55
|
+
label: string;
|
|
56
|
+
secret: string;
|
|
57
|
+
}): Promise<CredentialRecord>;
|
|
58
|
+
list(kind?: string): Promise<readonly CredentialRecord[]>;
|
|
59
|
+
get(credentialId: string): Promise<CredentialRecord | null>;
|
|
60
|
+
remove(credentialId: string): Promise<"deleted" | "absent">;
|
|
61
|
+
/** The only way out: the secret exists for the callback and nowhere else. */
|
|
62
|
+
withSecret<T>(credentialId: string, action: (secret: string, record: CredentialRecord) => Promise<T> | T): Promise<T>;
|
|
63
|
+
};
|
|
64
|
+
export type CredentialStore = ReturnType<typeof createCredentialStore>;
|