@adtl/gateway 0.7.0 → 0.8.1
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 +143 -11
- package/dist/audit.d.ts +3 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +10 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +167 -713
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +64 -7
- package/dist/config.js.map +1 -1
- package/dist/elicitation.d.ts +7 -1
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +21 -14
- package/dist/elicitation.js.map +1 -1
- package/dist/gateway.d.ts +11 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +13 -5
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/agentLinks.d.ts +18 -0
- package/dist/hooks/agentLinks.d.ts.map +1 -0
- package/dist/hooks/agentLinks.js +48 -0
- package/dist/hooks/agentLinks.js.map +1 -0
- package/dist/hooks/claudeCode.d.ts +4 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +35 -8
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +5 -2
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/init/suggest.d.ts +3 -1
- package/dist/init/suggest.d.ts.map +1 -1
- package/dist/init/suggest.js +8 -0
- package/dist/init/suggest.js.map +1 -1
- package/dist/invoker.d.ts +56 -6
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +121 -19
- package/dist/invoker.js.map +1 -1
- package/dist/openfga.d.ts +13 -0
- package/dist/openfga.d.ts.map +1 -0
- package/dist/openfga.js +101 -0
- package/dist/openfga.js.map +1 -0
- package/dist/startup.d.ts +10 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +82 -11
- package/dist/startup.js.map +1 -1
- package/dist/totp.d.ts +42 -0
- package/dist/totp.d.ts.map +1 -0
- package/dist/totp.js +176 -0
- package/dist/totp.js.map +1 -0
- package/dist/totpCli.d.ts +11 -0
- package/dist/totpCli.d.ts.map +1 -0
- package/dist/totpCli.js +46 -0
- package/dist/totpCli.js.map +1 -0
- package/package.json +8 -7
- package/playground/server.mjs +13 -3
- package/src/audit.ts +3 -1
- package/src/cli.ts +10 -1
- package/src/config.ts +71 -7
- package/src/elicitation.ts +35 -17
- package/src/gateway.ts +18 -5
- package/src/hooks/agentLinks.ts +58 -0
- package/src/hooks/claudeCode.ts +40 -9
- package/src/hooks/index.ts +1 -0
- package/src/hooks/run.ts +5 -2
- package/src/index.ts +3 -0
- package/src/init/suggest.ts +10 -1
- package/src/invoker.ts +153 -21
- package/src/openfga.ts +101 -0
- package/src/startup.ts +79 -11
- package/src/totp.ts +191 -0
- package/src/totpCli.ts +52 -0
- package/src/types/qrcode-terminal.d.ts +4 -0
package/src/invoker.ts
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* their mutations are not applied. An interceptor that fails (error or
|
|
12
12
|
* timeout) blocks the message unless it declares `failOpen: true`.
|
|
13
13
|
*
|
|
14
|
-
* The invoker may override
|
|
15
|
-
* (e.g. audit in staging, active in production)
|
|
14
|
+
* The invoker may override execution policy, as SEP-2624 allows: every
|
|
15
|
+
* interceptor's `mode` at once (e.g. audit in staging, active in production),
|
|
16
|
+
* and per interceptor its `failOpen`, `priorityHint`, `mode`, `timeoutMs` and
|
|
17
|
+
* `hooks` (narrowed only, never widened). A global mode wins over a
|
|
18
|
+
* per-interceptor one, so `--audit` always stops nothing.
|
|
16
19
|
*/
|
|
17
20
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
21
|
import {
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
LIST_METHOD,
|
|
22
25
|
type ConfirmationRequest,
|
|
23
26
|
type InterceptorDefinition,
|
|
27
|
+
type InterceptorHook,
|
|
24
28
|
type InterceptorResult,
|
|
25
29
|
type InvocationContext,
|
|
26
30
|
type MutationResult,
|
|
@@ -34,17 +38,49 @@ const AnyResult = z.object({}).passthrough();
|
|
|
34
38
|
|
|
35
39
|
type Loose = Record<string, unknown>;
|
|
36
40
|
|
|
37
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Untyped request: interceptor methods are extension methods the SDK does not type.
|
|
43
|
+
* The timeout is enforced here too, so a client that ignores it can't hang a phase.
|
|
44
|
+
*/
|
|
38
45
|
function request(client: Client, method: string, params: unknown, timeout: number): Promise<Loose> {
|
|
39
46
|
const r = client.request as unknown as (req: unknown, schema: unknown, opts: unknown) => Promise<Loose>;
|
|
40
|
-
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
let timer: NodeJS.Timeout | undefined;
|
|
49
|
+
const expired = new Promise<never>((_, reject) => {
|
|
50
|
+
timer = setTimeout(() => {
|
|
51
|
+
controller.abort();
|
|
52
|
+
reject(new Error(`timed out after ${timeout} ms`));
|
|
53
|
+
}, timeout);
|
|
54
|
+
});
|
|
55
|
+
return Promise.race([r.call(client, { method, params }, AnyResult, { timeout, signal: controller.signal }), expired]).finally(() =>
|
|
56
|
+
clearTimeout(timer),
|
|
57
|
+
);
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
export type InterceptorMode = NonNullable<InterceptorDefinition["mode"]>;
|
|
44
61
|
|
|
62
|
+
/** Invoker-side execution policy for one interceptor (SEP-2624 "Invoker Overrides"). */
|
|
63
|
+
export interface InterceptorOverrides {
|
|
64
|
+
failOpen?: boolean;
|
|
65
|
+
/** Ignored for validators. */
|
|
66
|
+
priorityHint?: InterceptorDefinition["priorityHint"];
|
|
67
|
+
mode?: InterceptorMode;
|
|
68
|
+
timeoutMs?: number;
|
|
69
|
+
/** Must be a subset of the interceptor's declared hooks. */
|
|
70
|
+
hooks?: InterceptorHook[];
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
export interface BoundInterceptor {
|
|
46
74
|
client: Client;
|
|
47
75
|
definition: InterceptorDefinition;
|
|
76
|
+
/** Id of the interceptor server that advertised it, for messages. */
|
|
77
|
+
server?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the gateway's per-call `config` (ADTL's server, tool and
|
|
80
|
+
* annotations) is sent. Other servers' interceptors get none, so their
|
|
81
|
+
* own defaults apply. Default: true.
|
|
82
|
+
*/
|
|
83
|
+
sendConfig?: boolean;
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
export interface Blocked {
|
|
@@ -68,6 +104,7 @@ export interface InterceptorRun {
|
|
|
68
104
|
modeFrom: "override" | "default";
|
|
69
105
|
failOpen: boolean;
|
|
70
106
|
failOpenFrom: "override" | "default";
|
|
107
|
+
timeoutMs: number;
|
|
71
108
|
/** Set when the invocation errored or timed out. */
|
|
72
109
|
failed?: true;
|
|
73
110
|
}
|
|
@@ -89,15 +126,77 @@ export interface InvokeOptions {
|
|
|
89
126
|
context: InvocationContext;
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
/** SEP-2624 `resolvePriority`: the override's hint, else the interceptor's. */
|
|
130
|
+
export function resolvePriority(def: InterceptorDefinition, overrides: InterceptorOverrides | undefined, phase: Phase): number {
|
|
131
|
+
const p = overrides?.priorityHint ?? def.priorityHint;
|
|
94
132
|
if (p === undefined) return 0;
|
|
95
133
|
if (typeof p === "number") return p;
|
|
96
134
|
return p[phase] ?? 0;
|
|
97
135
|
}
|
|
98
136
|
|
|
99
|
-
function hooks
|
|
100
|
-
return
|
|
137
|
+
function matches(hooks: InterceptorHook[], event: string, phase: Phase): boolean {
|
|
138
|
+
return hooks.some((h) => h.phase === phase && (h.events.includes(event) || h.events.includes("*")));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Problems with `overrides` for these interceptors: an unknown name, or hooks
|
|
143
|
+
* that widen what the interceptor declares (which SEP-2624 says to reject).
|
|
144
|
+
*/
|
|
145
|
+
export function checkOverrides(defs: InterceptorDefinition[], overrides: Record<string, InterceptorOverrides>): string[] {
|
|
146
|
+
const problems: string[] = [];
|
|
147
|
+
for (const [name, o] of Object.entries(overrides)) {
|
|
148
|
+
const def = defs.find((d) => d.name === name);
|
|
149
|
+
if (!def) {
|
|
150
|
+
problems.push(`${name}: no such interceptor (offered: ${defs.map((d) => d.name).join(", ")})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const h of o.hooks ?? []) {
|
|
154
|
+
for (const event of h.events) {
|
|
155
|
+
const declared = def.hooks.some((d) => d.phase === h.phase && (d.events.includes("*") || (event !== "*" && d.events.includes(event))));
|
|
156
|
+
if (!declared) problems.push(`${name}: hooks may only narrow the declared hooks, and ${event} (${h.phase}) is not declared`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return problems;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Interceptors of a type this invoker doesn't run (e.g. the proposed `sink`). They are never invoked. */
|
|
164
|
+
export function unsupportedInterceptors(bound: BoundInterceptor[]): string[] {
|
|
165
|
+
return bound
|
|
166
|
+
.filter((b) => b.definition.type !== "validation" && b.definition.type !== "mutation")
|
|
167
|
+
.map((b) => `${b.definition.name} (${String(b.definition.type)})`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Interceptor names offered by more than one server: each must be invoked on its own server, so names must be unique. */
|
|
171
|
+
export function duplicateInterceptors(bound: BoundInterceptor[]): string[] {
|
|
172
|
+
const servers = new Map<string, string[]>();
|
|
173
|
+
for (const b of bound) servers.set(b.definition.name, [...(servers.get(b.definition.name) ?? []), b.server ?? "?"]);
|
|
174
|
+
return [...servers].filter(([, s]) => s.length > 1).map(([name, s]) => `${name} (offered by ${s.join(", ")})`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Mutators that would run after `name` on a phase both hook, so they see (and
|
|
179
|
+
* could change or drop) what it wrote. Used to warn when another server's
|
|
180
|
+
* mutator runs after adtl.label.
|
|
181
|
+
*/
|
|
182
|
+
export function mutatorsAfter(bound: BoundInterceptor[], name: string, overrides: Record<string, InterceptorOverrides> = {}): string[] {
|
|
183
|
+
const target = bound.find((b) => b.definition.name === name);
|
|
184
|
+
if (!target) return [];
|
|
185
|
+
const hooksOf = (b: BoundInterceptor) => overrides[b.definition.name]?.hooks ?? b.definition.hooks;
|
|
186
|
+
const shares = (b: BoundInterceptor, phase: Phase) =>
|
|
187
|
+
hooksOf(b).some((h) => h.phase === phase && hooksOf(target).some((t) => t.phase === phase && (h.events.includes("*") || t.events.includes("*") || h.events.some((e) => t.events.includes(e)))));
|
|
188
|
+
const after: string[] = [];
|
|
189
|
+
for (const b of bound) {
|
|
190
|
+
if (b === target || b.definition.type !== "mutation") continue;
|
|
191
|
+
const late = (["request", "response"] as const).some((phase) => {
|
|
192
|
+
if (!shares(b, phase)) return false;
|
|
193
|
+
const p = resolvePriority(b.definition, overrides[b.definition.name], phase);
|
|
194
|
+
const t = resolvePriority(target.definition, overrides[name], phase);
|
|
195
|
+
return p > t || (p === t && b.definition.name.localeCompare(name) > 0);
|
|
196
|
+
});
|
|
197
|
+
if (late) after.push(b.definition.name);
|
|
198
|
+
}
|
|
199
|
+
return after;
|
|
101
200
|
}
|
|
102
201
|
|
|
103
202
|
export class InterceptorInvoker {
|
|
@@ -106,13 +205,15 @@ export class InterceptorInvoker {
|
|
|
106
205
|
private readonly timeoutMs = 5000,
|
|
107
206
|
/** Overrides the mode every interceptor declares. */
|
|
108
207
|
readonly modeOverride?: InterceptorMode,
|
|
208
|
+
/** Per-interceptor overrides, by name. Check them with `checkOverrides` first. */
|
|
209
|
+
private readonly overrides: Record<string, InterceptorOverrides> = {},
|
|
109
210
|
) {}
|
|
110
211
|
|
|
111
212
|
/** Discover the interceptors an interceptor server offers. */
|
|
112
|
-
static async discover(client: Client): Promise<BoundInterceptor[]> {
|
|
213
|
+
static async discover(client: Client, opts: { server?: string; sendConfig?: boolean } = {}): Promise<BoundInterceptor[]> {
|
|
113
214
|
const res = await request(client, LIST_METHOD, {}, 10_000);
|
|
114
215
|
const defs = (res.interceptors ?? []) as InterceptorDefinition[];
|
|
115
|
-
return defs.map((definition) => ({ client, definition }));
|
|
216
|
+
return defs.map((definition) => ({ client, definition, ...opts }));
|
|
116
217
|
}
|
|
117
218
|
|
|
118
219
|
get definitions(): InterceptorDefinition[] {
|
|
@@ -133,34 +234,63 @@ export class InterceptorInvoker {
|
|
|
133
234
|
return out;
|
|
134
235
|
}
|
|
135
236
|
|
|
237
|
+
private override(i: BoundInterceptor): InterceptorOverrides | undefined {
|
|
238
|
+
return this.overrides[i.definition.name];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private mode(i: BoundInterceptor): InterceptorMode {
|
|
242
|
+
return this.modeOverride ?? this.override(i)?.mode ?? i.definition.mode ?? "active";
|
|
243
|
+
}
|
|
244
|
+
|
|
136
245
|
private isAudit(i: BoundInterceptor): boolean {
|
|
137
|
-
return
|
|
246
|
+
return this.mode(i) === "audit";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private failOpen(i: BoundInterceptor): boolean {
|
|
250
|
+
return (this.override(i)?.failOpen ?? i.definition.failOpen) === true;
|
|
138
251
|
}
|
|
139
252
|
|
|
140
|
-
|
|
253
|
+
private timeout(i: BoundInterceptor): number {
|
|
254
|
+
return this.override(i)?.timeoutMs ?? this.timeoutMs;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Record that `i` runs in `phase`, with its resolved policy. */
|
|
141
258
|
private run(i: BoundInterceptor, phase: Phase, out: PhaseOutcome): InterceptorRun {
|
|
259
|
+
const o = this.override(i);
|
|
142
260
|
const r: InterceptorRun = {
|
|
143
261
|
name: i.definition.name,
|
|
144
262
|
phase,
|
|
145
|
-
mode: this.
|
|
146
|
-
modeFrom: this.modeOverride ? "override" : "default",
|
|
147
|
-
failOpen:
|
|
148
|
-
failOpenFrom: "default",
|
|
263
|
+
mode: this.mode(i),
|
|
264
|
+
modeFrom: this.modeOverride || o?.mode ? "override" : "default",
|
|
265
|
+
failOpen: this.failOpen(i),
|
|
266
|
+
failOpenFrom: o?.failOpen !== undefined ? "override" : "default",
|
|
267
|
+
timeoutMs: this.timeout(i),
|
|
149
268
|
};
|
|
150
269
|
out.ran.push(r);
|
|
151
270
|
return r;
|
|
152
271
|
}
|
|
153
272
|
|
|
154
273
|
private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
|
|
155
|
-
return this.interceptors.filter(
|
|
274
|
+
return this.interceptors.filter(
|
|
275
|
+
(i) => i.definition.type === type && matches(this.override(i)?.hooks ?? i.definition.hooks, event, phase),
|
|
276
|
+
);
|
|
156
277
|
}
|
|
157
278
|
|
|
158
279
|
private async invoke(i: BoundInterceptor, phase: Phase, opts: InvokeOptions, payload: unknown): Promise<InterceptorResult> {
|
|
280
|
+
const timeoutMs = this.timeout(i);
|
|
159
281
|
const res = await request(
|
|
160
282
|
i.client,
|
|
161
283
|
INVOKE_METHOD,
|
|
162
|
-
{
|
|
163
|
-
|
|
284
|
+
{
|
|
285
|
+
name: i.definition.name,
|
|
286
|
+
event: opts.event,
|
|
287
|
+
phase,
|
|
288
|
+
payload,
|
|
289
|
+
...(i.sendConfig === false ? {} : { config: opts.config }),
|
|
290
|
+
timeoutMs,
|
|
291
|
+
context: opts.context,
|
|
292
|
+
},
|
|
293
|
+
timeoutMs,
|
|
164
294
|
);
|
|
165
295
|
return res as unknown as InterceptorResult;
|
|
166
296
|
}
|
|
@@ -168,7 +298,7 @@ export class InterceptorInvoker {
|
|
|
168
298
|
/** Handle an interceptor failure. Returns false if the phase must stop. */
|
|
169
299
|
private failed(i: BoundInterceptor, err: unknown, out: PhaseOutcome): boolean {
|
|
170
300
|
const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
171
|
-
if (this.isAudit(i) ||
|
|
301
|
+
if (this.isAudit(i) || this.failOpen(i)) {
|
|
172
302
|
out.warnings.push(msg);
|
|
173
303
|
return true;
|
|
174
304
|
}
|
|
@@ -179,7 +309,9 @@ export class InterceptorInvoker {
|
|
|
179
309
|
/** Sequential mutations, atomic: on failure the original payload is kept. */
|
|
180
310
|
private async mutate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
|
|
181
311
|
const selected = this.select("mutation", opts.event, phase).sort(
|
|
182
|
-
(a, b) =>
|
|
312
|
+
(a, b) =>
|
|
313
|
+
resolvePriority(a.definition, this.override(a), phase) - resolvePriority(b.definition, this.override(b), phase) ||
|
|
314
|
+
a.definition.name.localeCompare(b.definition.name),
|
|
183
315
|
);
|
|
184
316
|
let payload = out.payload;
|
|
185
317
|
for (const i of selected) {
|
package/src/openfga.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relationship-based authorization with OpenFGA (`gateway.openfga`):
|
|
3
|
+
*
|
|
4
|
+
* openfga:
|
|
5
|
+
* url: http://localhost:8080
|
|
6
|
+
* storeId: "${FGA_STORE_ID}"
|
|
7
|
+
* modelId: "${FGA_MODEL_ID}" # optional: pin an authorization model
|
|
8
|
+
* token: "${FGA_TOKEN}" # optional: bearer token
|
|
9
|
+
* relation: can_use # optional: the default relation
|
|
10
|
+
* timeoutMs: 2000 # optional
|
|
11
|
+
* tools: # optional: per-resource rules, from the call's arguments
|
|
12
|
+
* github.add_issue_comment: { relation: can_comment, object: "repo:{owner}/{repo}" }
|
|
13
|
+
*
|
|
14
|
+
* Every call becomes one OpenFGA check:
|
|
15
|
+
*
|
|
16
|
+
* user user:<id>, service:<id>, or user:anonymous without an id
|
|
17
|
+
* relation the tool's rule, else `relation`
|
|
18
|
+
* object the tool's rule, else tool:<server>.<tool>, prompt:<server>.<name> or resource:<uri>
|
|
19
|
+
*
|
|
20
|
+
* Ids are percent-encoded, because OpenFGA ids can't hold `:`, `#`, `@` or
|
|
21
|
+
* spaces; ids too long for OpenFGA are replaced by their SHA-256. A rule's
|
|
22
|
+
* `{argument}` must be a string or number, or the call is denied. Tool lists
|
|
23
|
+
* have no arguments, so tools whose object needs them stay listed and are
|
|
24
|
+
* checked when called. An unreachable server or an error denies the call.
|
|
25
|
+
*
|
|
26
|
+
* Works with Auth0 FGA too, by pointing `url` at its API and passing a token.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import type { AuthorizationDecision, AuthorizationRequest, Authorizer } from "./authorizer.js";
|
|
30
|
+
import type { OpenFgaConfig } from "./config.js";
|
|
31
|
+
|
|
32
|
+
/** OpenFGA's limit on the id part of an object or user. */
|
|
33
|
+
const MAX_ID = 254;
|
|
34
|
+
|
|
35
|
+
function fgaId(value: string): string {
|
|
36
|
+
const id = encodeURIComponent(value);
|
|
37
|
+
return id.length <= MAX_ID ? id : `sha256-${createHash("sha256").update(value).digest("hex")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PLACEHOLDER = /\{([A-Za-z0-9_.-]+)\}/g;
|
|
41
|
+
|
|
42
|
+
export class OpenFgaAuthorizer implements Authorizer {
|
|
43
|
+
readonly name = "openfga";
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly cfg: OpenFgaConfig,
|
|
47
|
+
private readonly fetchFn: typeof fetch = fetch,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
private user(req: AuthorizationRequest): string {
|
|
51
|
+
const p = req.principal;
|
|
52
|
+
if (p.type === "anonymous" || !p.id) return "user:anonymous";
|
|
53
|
+
return `${p.type === "service" ? "service" : "user"}:${fgaId(p.id)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The object of this call, or a reason it can't be built. "listing" means: decide when called. */
|
|
57
|
+
private object(req: AuthorizationRequest, template?: string): { object: string } | { missing: string } | "listing" {
|
|
58
|
+
if (template !== undefined) {
|
|
59
|
+
if (req.arguments === undefined && template.search(PLACEHOLDER) !== -1) return "listing";
|
|
60
|
+
const colon = template.indexOf(":");
|
|
61
|
+
const type = template.slice(0, colon + 1);
|
|
62
|
+
let missing: string | undefined;
|
|
63
|
+
const id = template.slice(colon + 1).replace(PLACEHOLDER, (_, name: string) => {
|
|
64
|
+
const v = req.arguments?.[name];
|
|
65
|
+
if (typeof v !== "string" && typeof v !== "number") {
|
|
66
|
+
missing ??= name;
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
return String(v);
|
|
70
|
+
});
|
|
71
|
+
return missing !== undefined ? { missing } : { object: `${type}${fgaId(id)}` };
|
|
72
|
+
}
|
|
73
|
+
if (req.tool !== undefined) return { object: `tool:${fgaId(`${req.server}.${req.tool}`)}` };
|
|
74
|
+
if (req.prompt !== undefined) return { object: `prompt:${fgaId(`${req.server}.${req.prompt}`)}` };
|
|
75
|
+
return { object: `resource:${fgaId(req.resource ?? "")}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async check(req: AuthorizationRequest): Promise<AuthorizationDecision> {
|
|
79
|
+
const rule = req.tool !== undefined ? this.cfg.tools[`${req.server}.${req.tool}`] : undefined;
|
|
80
|
+
const target = this.object(req, rule?.object);
|
|
81
|
+
if (target === "listing") return { allow: true };
|
|
82
|
+
const what = req.tool ?? req.prompt ?? req.resource;
|
|
83
|
+
if ("missing" in target) return { allow: false, reason: `openfga: ${what} has no usable argument "${target.missing}"` };
|
|
84
|
+
|
|
85
|
+
const user = this.user(req);
|
|
86
|
+
const relation = rule?.relation ?? this.cfg.relation;
|
|
87
|
+
const res = await this.fetchFn(`${this.cfg.url.replace(/\/+$/, "")}/stores/${encodeURIComponent(this.cfg.storeId)}/check`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json", ...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}) },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
tuple_key: { user, relation, object: target.object },
|
|
92
|
+
...(this.cfg.modelId ? { authorization_model_id: this.cfg.modelId } : {}),
|
|
93
|
+
}),
|
|
94
|
+
signal: AbortSignal.timeout(this.cfg.timeoutMs),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) throw new Error(`OpenFGA answered ${res.status}`);
|
|
97
|
+
const body = (await res.json()) as { allowed?: unknown };
|
|
98
|
+
if (body.allowed === true) return { allow: true };
|
|
99
|
+
return { allow: false, reason: `OpenFGA: ${user} has no ${relation} on ${target.object}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/startup.ts
CHANGED
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
} from "./config.js";
|
|
29
29
|
import { EmailNotifier } from "./email.js";
|
|
30
30
|
import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
|
|
31
|
-
import { InterceptorInvoker } from "./invoker.js";
|
|
31
|
+
import { InterceptorInvoker, checkOverrides, duplicateInterceptors, mutatorsAfter, unsupportedInterceptors } from "./invoker.js";
|
|
32
|
+
import { OpenFgaAuthorizer } from "./openfga.js";
|
|
33
|
+
import { loadTotpVerifier } from "./totp.js";
|
|
32
34
|
import { NtfyNotifier } from "./ntfy.js";
|
|
33
35
|
import { RoleAuthorizer } from "./roles.js";
|
|
34
36
|
|
|
@@ -124,12 +126,13 @@ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log:
|
|
|
124
126
|
const approverChannels = new Map<string, Notifier[]>(
|
|
125
127
|
Object.entries(a.approvers ?? {}).map(([id, c]) => [id, [new NtfyNotifier({ ...c.ntfy, name: `ntfy:${id}` })]]),
|
|
126
128
|
);
|
|
127
|
-
if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation) return undefined;
|
|
128
|
-
if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation) {
|
|
129
|
+
if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation && !a.totp) return undefined;
|
|
130
|
+
if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation && !a.totp) {
|
|
129
131
|
log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
|
|
130
132
|
}
|
|
131
133
|
const approvers = approverChannels.size ? `; approvers: ${[...approverChannels.keys()].join(", ")}` : "";
|
|
132
|
-
const
|
|
134
|
+
const inClient = a.totp ? ["authenticator code (if the client supports elicitation)"] : a.elicitation ? ["elicitation (if the client supports it)"] : [];
|
|
135
|
+
const shared = [...notifiers.map((n) => n.name), ...inClient];
|
|
133
136
|
log.info(`approvals: ${shared.join(", ") || "none shared"}${approvers} (timeout ${a.timeoutSeconds}s)`);
|
|
134
137
|
return new ApprovalBroker({ notifiers, approverChannels, timeoutMs: a.timeoutSeconds * 1000, log });
|
|
135
138
|
}
|
|
@@ -175,7 +178,7 @@ export function gatewayPrincipal(cfg: GatewayConfig, env: NodeJS.ProcessEnv): Pr
|
|
|
175
178
|
* The configured authorizers, in the order they are asked: roles, then cedar.
|
|
176
179
|
* Cedar files are read and parsed here, so a bad policy fails startup.
|
|
177
180
|
*/
|
|
178
|
-
export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): Promise<Authorizer[]> {
|
|
181
|
+
export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string, env: NodeJS.ProcessEnv = process.env): Promise<Authorizer[]> {
|
|
179
182
|
const out: Authorizer[] = [];
|
|
180
183
|
if (cfg.roles) out.push(new RoleAuthorizer(cfg.roles));
|
|
181
184
|
if (cfg.cedar) {
|
|
@@ -185,6 +188,19 @@ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): P
|
|
|
185
188
|
throw new GatewayConfigError((err as Error).message);
|
|
186
189
|
}
|
|
187
190
|
}
|
|
191
|
+
if (cfg.openfga) {
|
|
192
|
+
const f = cfg.openfga;
|
|
193
|
+
const expand = (v: string, key: string) => expandEnv(v, env, `gateway.openfga.${key}`);
|
|
194
|
+
out.push(
|
|
195
|
+
new OpenFgaAuthorizer({
|
|
196
|
+
...f,
|
|
197
|
+
url: expand(f.url, "url"),
|
|
198
|
+
storeId: expand(f.storeId, "storeId"),
|
|
199
|
+
...(f.modelId !== undefined ? { modelId: expand(f.modelId, "modelId") } : {}),
|
|
200
|
+
...(f.token !== undefined ? { token: expand(f.token, "token") } : {}),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
188
204
|
return out;
|
|
189
205
|
}
|
|
190
206
|
|
|
@@ -192,12 +208,23 @@ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): P
|
|
|
192
208
|
* Under Claude Code, which passes `CLAUDE_CODE_SESSION_ID` to stdio MCP
|
|
193
209
|
* servers, the gateway uses that id and keeps session labels in files, so the
|
|
194
210
|
* Claude Code hooks (`adtl-gateway hook`) add to the same session label.
|
|
211
|
+
*
|
|
212
|
+
* `ADTL_SESSION_ID` does the same for any agent framework, and wins over
|
|
213
|
+
* Claude Code's: a sub-agent's gateway started with its parent's session id
|
|
214
|
+
* starts from the parent's label, and what it reads taints the parent too.
|
|
195
215
|
*/
|
|
196
216
|
export function sharedSession(env: NodeJS.ProcessEnv): { sessionId: string; dir: string } | undefined {
|
|
197
|
-
const sessionId = env.CLAUDE_CODE_SESSION_ID?.trim();
|
|
217
|
+
const sessionId = env.ADTL_SESSION_ID?.trim() || env.CLAUDE_CODE_SESSION_ID?.trim();
|
|
198
218
|
return sessionId ? { sessionId, dir: defaultSessionsDir(env) } : undefined;
|
|
199
219
|
}
|
|
200
220
|
|
|
221
|
+
/** `ADTL_AGENT_ID` (and `ADTL_AGENT_TYPE`): which agent this gateway serves, for the audit log. */
|
|
222
|
+
export function gatewayAgent(env: NodeJS.ProcessEnv): { id: string; type?: string } | undefined {
|
|
223
|
+
const id = env.ADTL_AGENT_ID?.trim();
|
|
224
|
+
const type = env.ADTL_AGENT_TYPE?.trim();
|
|
225
|
+
return id ? { id, ...(type ? { type } : {}) } : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
201
228
|
export interface StartedGateway {
|
|
202
229
|
gateway: AdtlGateway;
|
|
203
230
|
close(): Promise<void>;
|
|
@@ -211,7 +238,7 @@ export async function startGateway(
|
|
|
211
238
|
// Config problems that need no process: fail before starting any.
|
|
212
239
|
const approverMembers = approverRoles(loaded.labels, loaded.gateway);
|
|
213
240
|
const baseDir = dirname(loaded.path);
|
|
214
|
-
const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir);
|
|
241
|
+
const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir, env);
|
|
215
242
|
const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
|
|
216
243
|
const clients: Client[] = [];
|
|
217
244
|
const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
|
|
@@ -237,15 +264,50 @@ export async function startGateway(
|
|
|
237
264
|
if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
|
|
238
265
|
throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
|
|
239
266
|
}
|
|
240
|
-
const
|
|
241
|
-
const
|
|
267
|
+
const own = await InterceptorInvoker.discover(interceptors, { server: "interceptors" });
|
|
268
|
+
const ownNames = own.map((b) => b.definition.name);
|
|
242
269
|
for (const required of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
|
|
243
|
-
if (!
|
|
270
|
+
if (!ownNames.includes(required)) {
|
|
244
271
|
throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
|
|
245
272
|
}
|
|
246
273
|
}
|
|
274
|
+
// More interceptor servers (SEP-2624: interceptors may come from N servers, each invoked where it was advertised).
|
|
275
|
+
// A server that can't be reached stops startup: the owner asked for its interceptors.
|
|
276
|
+
const bound = [...own];
|
|
277
|
+
for (const [id, server] of Object.entries(loaded.gateway.interceptorServers)) {
|
|
278
|
+
const where = `gateway.interceptorServers.${id}`;
|
|
279
|
+
let client: Client;
|
|
280
|
+
try {
|
|
281
|
+
client = await connectUpstream(`interceptors:${id}`, expandUpstream(id, server, env), baseDir);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new GatewayConfigError(`${where}: could not connect: ${err instanceof Error ? err.message : String(err)}`);
|
|
284
|
+
}
|
|
285
|
+
clients.push(client);
|
|
286
|
+
if (!client.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
|
|
287
|
+
throw new GatewayConfigError(`${where} does not declare the ${INTERCEPTORS_EXTENSION} extension`);
|
|
288
|
+
}
|
|
289
|
+
bound.push(...(await InterceptorInvoker.discover(client, { server: id, sendConfig: false })));
|
|
290
|
+
}
|
|
291
|
+
const duplicates = duplicateInterceptors(bound);
|
|
292
|
+
if (duplicates.length) throw new GatewayConfigError(`Interceptor names must be unique across servers: ${duplicates.join("; ")}`);
|
|
293
|
+
const names = bound.map((b) => b.definition.name);
|
|
294
|
+
const overrides = loaded.gateway.interceptorOverrides;
|
|
295
|
+
const problems = checkOverrides(bound.map((b) => b.definition), overrides);
|
|
296
|
+
if (problems.length) throw new GatewayConfigError(`gateway.interceptorOverrides: ${problems.join("; ")}`);
|
|
297
|
+
const unsupported = unsupportedInterceptors(bound);
|
|
298
|
+
if (unsupported.length) log.warn(`not running interceptors of an unknown type: ${unsupported.join(", ")}`);
|
|
299
|
+
const late = mutatorsAfter(bound, LABEL_INTERCEPTOR, overrides);
|
|
300
|
+
if (late.length) {
|
|
301
|
+
log.warn(`${late.join(", ")} run after ${LABEL_INTERCEPTOR} and could change or drop its label; give them a lower priorityHint`);
|
|
302
|
+
}
|
|
303
|
+
for (const name of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
|
|
304
|
+
const o = overrides[name];
|
|
305
|
+
if (o?.mode === "audit" || o?.failOpen === true || o?.hooks) {
|
|
306
|
+
log.warn(`gateway.interceptorOverrides relaxes ${name} (${Object.keys(o).join(", ")}): calls it would stop may get through`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
247
309
|
const audit = loaded.gateway.mode === "audit";
|
|
248
|
-
const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
|
|
310
|
+
const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined, overrides);
|
|
249
311
|
log.info(`interceptors: ${names.join(", ")}`);
|
|
250
312
|
if (shared) log.info(`sharing session labels with Claude Code hooks (${shared.dir})`);
|
|
251
313
|
if (audit) log.warn("audit mode: calls are logged but never stopped or changed");
|
|
@@ -280,8 +342,14 @@ export async function startGateway(
|
|
|
280
342
|
if (!principal) log.warn('roles are set but gateway.principal is not: only roles with members ["*"] apply');
|
|
281
343
|
}
|
|
282
344
|
for (const a of authorizers) if (a.name !== "roles") log.info(`authorizer: ${a.name}`);
|
|
345
|
+
const totp = loaded.gateway.approvals?.totp ? loadTotpVerifier(env) : undefined;
|
|
346
|
+
if (loaded.gateway.approvals?.totp && !totp) throw new GatewayConfigError("gateway.approvals.totp is on, but no secret is set up: run adtl-gateway totp setup");
|
|
347
|
+
const agent = gatewayAgent(env);
|
|
348
|
+
if (agent) log.info(`agent: ${agent.id}${agent.type ? ` (${agent.type})` : ""}`);
|
|
283
349
|
const gateway = new AdtlGateway({
|
|
284
350
|
...(shared ? { sessionId: shared.sessionId } : {}),
|
|
351
|
+
...(agent ? { agent } : {}),
|
|
352
|
+
...(totp ? { totp } : {}),
|
|
285
353
|
upstreams,
|
|
286
354
|
invoker,
|
|
287
355
|
...(auditLog ? { audit: auditLog } : {}),
|