@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/config.ts
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* docs:
|
|
13
13
|
* url: https://example.com/mcp
|
|
14
14
|
* headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
|
|
15
|
+
* interceptorServers: # optional: more interceptor servers, run beside ADTL's
|
|
16
|
+
* pii: { url: https://pii.example.com/mcp }
|
|
17
|
+
* interceptorOverrides: # optional: per-interceptor execution policy (SEP-2624)
|
|
18
|
+
* adtl.label: { timeoutMs: 10000 }
|
|
15
19
|
* principal: { id: "${USER}" } # optional: who runs this gateway (default: anonymous)
|
|
16
20
|
* roles: # optional: who may use which tools (deny by default)
|
|
17
21
|
* maintainers: { members: [alice], tools: ["github.*"] }
|
|
18
22
|
* readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
|
|
19
23
|
* cedar: { policies: ./adtl.cedar } # optional: attribute-based rules (see cedar.ts)
|
|
24
|
+
* openfga: { url: http://localhost:8080, storeId: "${FGA_STORE_ID}" } # optional: relationship rules (see openfga.ts)
|
|
20
25
|
* approvals: # optional: make "ask" really ask
|
|
21
26
|
* timeoutSeconds: 50
|
|
22
27
|
* ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
|
|
@@ -27,7 +32,7 @@
|
|
|
27
32
|
* from: adtl@example.com
|
|
28
33
|
* to: you@example.com
|
|
29
34
|
*
|
|
30
|
-
* `${VAR}` in command, args, env, cwd, url, headers, approval settings and principal.id is
|
|
35
|
+
* `${VAR}` in command, args, env, cwd, url, headers, approval settings, openfga settings and principal.id is
|
|
31
36
|
* replaced from the gateway's environment. A missing variable is a config error,
|
|
32
37
|
* unless a default is given: `${VAR:-default}`.
|
|
33
38
|
*/
|
|
@@ -40,7 +45,7 @@ export const StdioUpstreamSchema = z
|
|
|
40
45
|
.object({
|
|
41
46
|
command: z.string().min(1),
|
|
42
47
|
args: z.array(z.string()).default([]),
|
|
43
|
-
env: z.record(z.string()).default({}),
|
|
48
|
+
env: z.record(z.string(), z.string()).default({}),
|
|
44
49
|
cwd: z.string().optional(),
|
|
45
50
|
})
|
|
46
51
|
.strict();
|
|
@@ -48,7 +53,7 @@ export const StdioUpstreamSchema = z
|
|
|
48
53
|
export const HttpUpstreamSchema = z
|
|
49
54
|
.object({
|
|
50
55
|
url: z.string().min(1),
|
|
51
|
-
headers: z.record(z.string()).default({}),
|
|
56
|
+
headers: z.record(z.string(), z.string()).default({}),
|
|
52
57
|
})
|
|
53
58
|
.strict();
|
|
54
59
|
|
|
@@ -100,11 +105,16 @@ export const ApprovalsConfigSchema = z
|
|
|
100
105
|
* default: a client that accepts elicitations by itself would approve calls.
|
|
101
106
|
*/
|
|
102
107
|
elicitation: z.boolean().default(false),
|
|
108
|
+
/**
|
|
109
|
+
* Ask in the MCP client for a code from an authenticator app (`adtl-gateway
|
|
110
|
+
* totp setup`), instead of Allow or Deny. Wins over `elicitation`.
|
|
111
|
+
*/
|
|
112
|
+
totp: z.boolean().default(false),
|
|
103
113
|
/**
|
|
104
114
|
* Each approver's own channel, by principal id. Policy rules with
|
|
105
115
|
* `approvers: [role]` ask only these people, so the answer names who approved.
|
|
106
116
|
*/
|
|
107
|
-
approvers: z.record(z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
|
|
117
|
+
approvers: z.record(z.string(), z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
|
|
108
118
|
})
|
|
109
119
|
.strict();
|
|
110
120
|
export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
|
|
@@ -119,7 +129,7 @@ export const PrincipalConfigSchema = z
|
|
|
119
129
|
.object({
|
|
120
130
|
type: z.enum(["user", "service"]).default("user"),
|
|
121
131
|
id: z.string().min(1),
|
|
122
|
-
claims: z.record(z.unknown()).optional(),
|
|
132
|
+
claims: z.record(z.string(), z.unknown()).optional(),
|
|
123
133
|
})
|
|
124
134
|
.strict();
|
|
125
135
|
export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
|
|
@@ -162,16 +172,66 @@ export const CedarConfigSchema = z
|
|
|
162
172
|
.strict();
|
|
163
173
|
export type CedarConfig = z.infer<typeof CedarConfigSchema>;
|
|
164
174
|
|
|
175
|
+
export const OpenFgaConfigSchema = z
|
|
176
|
+
.object({
|
|
177
|
+
url: z.string().min(1),
|
|
178
|
+
storeId: z.string().min(1),
|
|
179
|
+
modelId: z.string().min(1).optional(),
|
|
180
|
+
token: z.string().min(1).optional(),
|
|
181
|
+
/** Relation checked when a tool has no rule of its own. */
|
|
182
|
+
relation: z.string().min(1).default("can_use"),
|
|
183
|
+
timeoutMs: z.number().int().positive().max(60_000).default(2000),
|
|
184
|
+
/** Per-tool rules, keyed `server.tool`. `object` is `type:id`, and `{argument}` is taken from the call. */
|
|
185
|
+
tools: z
|
|
186
|
+
.record(
|
|
187
|
+
z.string(),
|
|
188
|
+
z
|
|
189
|
+
.object({
|
|
190
|
+
relation: z.string().min(1).optional(),
|
|
191
|
+
object: z.string().regex(/^[^:\s]+:.+$/, 'Use "type:id", e.g. "repo:{owner}/{repo}"').optional(),
|
|
192
|
+
})
|
|
193
|
+
.strict(),
|
|
194
|
+
)
|
|
195
|
+
.default({}),
|
|
196
|
+
})
|
|
197
|
+
.strict();
|
|
198
|
+
export type OpenFgaConfig = z.infer<typeof OpenFgaConfigSchema>;
|
|
199
|
+
|
|
200
|
+
const PhaseSchema = z.enum(["request", "response"]);
|
|
201
|
+
|
|
202
|
+
/** SEP-2624 InterceptorOverrides, set by the gateway owner for one interceptor. */
|
|
203
|
+
export const InterceptorOverridesSchema = z
|
|
204
|
+
.object({
|
|
205
|
+
failOpen: z.boolean().optional(),
|
|
206
|
+
priorityHint: z
|
|
207
|
+
.union([z.number().int(), z.object({ request: z.number().int().optional(), response: z.number().int().optional() }).strict()])
|
|
208
|
+
.optional(),
|
|
209
|
+
mode: z.enum(["active", "audit"]).optional(),
|
|
210
|
+
timeoutMs: z.number().int().positive().max(600_000).optional(),
|
|
211
|
+
hooks: z
|
|
212
|
+
.array(z.object({ events: z.array(z.string().min(1)).min(1), phase: PhaseSchema }).strict())
|
|
213
|
+
.min(1, "hooks: list at least one hook, or leave it out to keep the declared hooks")
|
|
214
|
+
.optional(),
|
|
215
|
+
})
|
|
216
|
+
.strict();
|
|
217
|
+
|
|
165
218
|
export const GatewayConfigSchema = z
|
|
166
219
|
.object({
|
|
167
220
|
upstreams: z
|
|
168
|
-
.record(UpstreamSchema)
|
|
221
|
+
.record(z.string(), UpstreamSchema)
|
|
169
222
|
.refine((u) => Object.keys(u).length > 0, "At least one upstream server is required")
|
|
170
223
|
.refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
|
|
171
224
|
message: "Upstream ids may only contain letters, digits and '-' (they prefix tool names as <id>__<tool>)",
|
|
172
225
|
}),
|
|
173
226
|
/** How to start the interceptor server. Default: the bundled adtl-interceptors with this same config file. */
|
|
174
227
|
interceptors: StdioUpstreamSchema.optional(),
|
|
228
|
+
/** More interceptor servers (stdio or Streamable HTTP) whose interceptors run beside ADTL's. */
|
|
229
|
+
interceptorServers: z
|
|
230
|
+
.record(z.string(), UpstreamSchema)
|
|
231
|
+
.refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
|
|
232
|
+
message: "Interceptor server ids may only contain letters, digits and '-'",
|
|
233
|
+
})
|
|
234
|
+
.default({}),
|
|
175
235
|
/** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
|
|
176
236
|
audit: z.string().min(1).optional(),
|
|
177
237
|
/**
|
|
@@ -183,15 +243,19 @@ export const GatewayConfigSchema = z
|
|
|
183
243
|
approvals: ApprovalsConfigSchema.optional(),
|
|
184
244
|
/** Timeout for each interceptor invocation, in milliseconds. */
|
|
185
245
|
interceptorTimeoutMs: z.number().int().positive().default(5000),
|
|
246
|
+
/** Per-interceptor overrides of failOpen, priorityHint, mode, timeoutMs and hooks, by interceptor name. */
|
|
247
|
+
interceptorOverrides: z.record(z.string(), InterceptorOverridesSchema).default({}),
|
|
186
248
|
/** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
|
|
187
249
|
principal: PrincipalConfigSchema.optional(),
|
|
188
250
|
/** Role-based authorization: when present, a call no role grants is denied. */
|
|
189
251
|
roles: z
|
|
190
|
-
.record(RoleSchema)
|
|
252
|
+
.record(z.string(), RoleSchema)
|
|
191
253
|
.refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
|
|
192
254
|
.optional(),
|
|
193
255
|
/** Cedar authorization. With roles as well, both must allow. */
|
|
194
256
|
cedar: CedarConfigSchema.optional(),
|
|
257
|
+
/** OpenFGA authorization. With roles or cedar as well, all must allow. */
|
|
258
|
+
openfga: OpenFgaConfigSchema.optional(),
|
|
195
259
|
})
|
|
196
260
|
.strict();
|
|
197
261
|
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
package/src/elicitation.ts
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
* when `gateway.approvals.elicitation` is on and the client declares the
|
|
10
10
|
* elicitation capability, and never for rules with named approvers: the
|
|
11
11
|
* client can't say who answered.
|
|
12
|
+
*
|
|
13
|
+
* With `gateway.approvals.totp`, the prompt asks for a code from the person's
|
|
14
|
+
* authenticator app instead of Allow or Deny, so a client that accepts
|
|
15
|
+
* prompts by itself can't approve anything. Three wrong codes count as Deny.
|
|
12
16
|
*/
|
|
13
17
|
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
14
18
|
import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
15
19
|
import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
|
|
20
|
+
import type { TotpVerifier } from "./totp.js";
|
|
16
21
|
|
|
17
22
|
interface Listening {
|
|
18
23
|
answer: AnswerFn;
|
|
@@ -29,11 +34,22 @@ function message(request: ApprovalRequest): string {
|
|
|
29
34
|
return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. Allow runs this one call; Deny or no answer stops it.`;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
function codeMessage(request: ApprovalRequest, wrong: number): string {
|
|
38
|
+
const rule = request.matched.join(", ") || "default";
|
|
39
|
+
const again = wrong > 0 ? "That code was not right. " : "";
|
|
40
|
+
return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. ${again}Enter the code from your authenticator app to run this one call; decline or no answer stops it.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MAX_CODE_ATTEMPTS = 3;
|
|
44
|
+
|
|
32
45
|
export class ElicitationChannel implements Notifier {
|
|
33
46
|
readonly name = "elicitation";
|
|
34
47
|
private readonly listening = new Map<string, Listening>();
|
|
35
48
|
|
|
36
|
-
constructor(
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly server: Server,
|
|
51
|
+
private readonly totp?: TotpVerifier,
|
|
52
|
+
) {}
|
|
37
53
|
|
|
38
54
|
async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
|
|
39
55
|
this.listening.set(request.id, { answer, signal });
|
|
@@ -46,22 +62,24 @@ export class ElicitationChannel implements Notifier {
|
|
|
46
62
|
const l = this.listening.get(event.request.id);
|
|
47
63
|
if (!l) throw new Error("not listening for this request");
|
|
48
64
|
// A plain request, not Server.elicitInput(): clients that declare only `elicitation: {}` support forms too.
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
method: "elicitation/create",
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
const ask = (text: string, properties: Record<string, unknown>, required: string) =>
|
|
66
|
+
this.server.request(
|
|
67
|
+
{ method: "elicitation/create", params: { message: text, requestedSchema: { type: "object", properties, required: [required] } } } as never,
|
|
68
|
+
ElicitResultSchema,
|
|
69
|
+
{ signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
|
|
70
|
+
);
|
|
71
|
+
if (this.totp) {
|
|
72
|
+
for (let wrong = 0; wrong < MAX_CODE_ATTEMPTS; wrong++) {
|
|
73
|
+
const r = await ask(codeMessage(event.request, wrong), { code: { type: "string", title: "Code", description: "6 digits from your authenticator app" } }, "code");
|
|
74
|
+
if (r.action !== "accept") break;
|
|
75
|
+
if (this.totp.verify(String(r.content?.code ?? ""))) return void l.answer(event.tokens.allow);
|
|
76
|
+
}
|
|
77
|
+
return void l.answer(event.tokens.deny);
|
|
78
|
+
}
|
|
79
|
+
const result = await ask(
|
|
80
|
+
message(event.request),
|
|
81
|
+
{ decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" } },
|
|
82
|
+
"decision",
|
|
65
83
|
);
|
|
66
84
|
const allow = result.action === "accept" && result.content?.decision === "allow";
|
|
67
85
|
l.answer(allow ? event.tokens.allow : event.tokens.deny);
|
package/src/gateway.ts
CHANGED
|
@@ -57,12 +57,13 @@ import type { InvocationContext } from "@adtl/interceptors";
|
|
|
57
57
|
import { callBindingHash } from "@adtl/labels";
|
|
58
58
|
import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
|
|
59
59
|
import { ElicitationChannel, supportsElicitation } from "./elicitation.js";
|
|
60
|
+
import type { TotpVerifier } from "./totp.js";
|
|
60
61
|
import type { AuditEntry, AuditSink } from "./audit.js";
|
|
61
62
|
import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
|
|
62
63
|
import { TOOL_SEPARATOR } from "./config.js";
|
|
63
64
|
import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
|
|
64
65
|
|
|
65
|
-
export const GATEWAY_VERSION = "0.
|
|
66
|
+
export const GATEWAY_VERSION = "0.8.1";
|
|
66
67
|
|
|
67
68
|
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
68
69
|
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
@@ -115,6 +116,10 @@ export interface AdtlGatewayOptions {
|
|
|
115
116
|
approverRoles?: Map<string, string[]>;
|
|
116
117
|
/** Also ask for approval in the MCP client, when it supports elicitation. */
|
|
117
118
|
elicitApprovals?: boolean;
|
|
119
|
+
/** Ask for a code from an authenticator app in the MCP client, instead of Allow or Deny. */
|
|
120
|
+
totp?: TotpVerifier;
|
|
121
|
+
/** The (sub-)agent this gateway serves, recorded on every audit entry. */
|
|
122
|
+
agent?: { id: string; type?: string };
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
@@ -174,8 +179,10 @@ export class AdtlGateway {
|
|
|
174
179
|
private readonly approvals: ApprovalBroker | undefined;
|
|
175
180
|
private readonly authorizers: Authorizer[];
|
|
176
181
|
private readonly principal: Principal;
|
|
182
|
+
private readonly agent: { agent: { id: string; type?: string } } | Record<string, never>;
|
|
177
183
|
private readonly approverRoles: Map<string, string[]>;
|
|
178
184
|
private readonly elicitApprovals: boolean;
|
|
185
|
+
private readonly totp: TotpVerifier | undefined;
|
|
179
186
|
/** The server the agent is connected to, for elicitation. */
|
|
180
187
|
private server: Server | undefined;
|
|
181
188
|
private routes = new Map<string, Route>();
|
|
@@ -193,8 +200,10 @@ export class AdtlGateway {
|
|
|
193
200
|
this.approvals = opts.approvals;
|
|
194
201
|
this.authorizers = opts.authorizer === undefined ? [] : [opts.authorizer].flat();
|
|
195
202
|
this.principal = opts.principal ?? { type: "anonymous" };
|
|
203
|
+
this.agent = opts.agent ? { agent: opts.agent } : {};
|
|
196
204
|
this.approverRoles = opts.approverRoles ?? new Map();
|
|
197
205
|
this.elicitApprovals = opts.elicitApprovals === true;
|
|
206
|
+
this.totp = opts.totp;
|
|
198
207
|
}
|
|
199
208
|
|
|
200
209
|
/** Ask each authorizer in turn. Returns the first denial; in audit mode the caller logs it and carries on. */
|
|
@@ -450,6 +459,7 @@ export class AdtlGateway {
|
|
|
450
459
|
this.audit?.write({
|
|
451
460
|
ts: context.timestamp!,
|
|
452
461
|
sessionId: this.sessionId,
|
|
462
|
+
...this.agent,
|
|
453
463
|
event,
|
|
454
464
|
server: upstream,
|
|
455
465
|
...target,
|
|
@@ -501,7 +511,7 @@ export class AdtlGateway {
|
|
|
501
511
|
}
|
|
502
512
|
const label = infoOf(res, "adtl.label");
|
|
503
513
|
finish({ outcome: "forwarded", ...audit, label: label.label, ...(label.ledger ? { ledger: label.ledger } : {}),
|
|
504
|
-
...(label.records ? { records: label.records } : {}), sessionLabel: label.sessionLabel, warnings });
|
|
514
|
+
...(label.records ? { records: label.records } : {}), ...(label.recordIds ? { recordIds: label.recordIds } : {}), sessionLabel: label.sessionLabel, warnings });
|
|
505
515
|
return (res.payload as { result: R }).result;
|
|
506
516
|
}
|
|
507
517
|
|
|
@@ -525,7 +535,7 @@ export class AdtlGateway {
|
|
|
525
535
|
server: route.upstream,
|
|
526
536
|
...(route.annotations ? { toolAnnotations: route.annotations } : {}),
|
|
527
537
|
};
|
|
528
|
-
const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
538
|
+
const base = { sessionId: this.sessionId, ...this.agent, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
529
539
|
let denied: Denial | undefined;
|
|
530
540
|
const ran: InterceptorRun[] = [];
|
|
531
541
|
const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
|
|
@@ -568,7 +578,7 @@ export class AdtlGateway {
|
|
|
568
578
|
const lists = Array.isArray(policy.approvers) ? (policy.approvers as string[][]) : undefined;
|
|
569
579
|
const approvers = lists ? eligibleApprovers(lists, this.approverRoles) : undefined;
|
|
570
580
|
// The client's own prompt, if enabled and supported; never for named approvers (it can't say who answered).
|
|
571
|
-
const elicit = this.elicitApprovals && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server)] : [];
|
|
581
|
+
const elicit = (this.elicitApprovals || this.totp) && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server, this.totp)] : [];
|
|
572
582
|
if (b.decision === "ask" && this.approvals && (this.approvals.canApprove || elicit.length > 0) && typeof hash === "string") {
|
|
573
583
|
this.log.info(`ask ${name}: waiting for approval${approvers ? ` from ${approvers.join(", ") || "nobody"}` : ""} (${b.reason})`);
|
|
574
584
|
approval = await this.waitForApproval(opts, () =>
|
|
@@ -630,7 +640,9 @@ export class AdtlGateway {
|
|
|
630
640
|
// 2. Forward to the upstream server.
|
|
631
641
|
let result: CallToolResult;
|
|
632
642
|
try {
|
|
633
|
-
|
|
643
|
+
// A mutator may add request _meta (e.g. fides-gateway labels); pass it on.
|
|
644
|
+
const meta = (forwarded as { _meta?: Record<string, unknown> })._meta;
|
|
645
|
+
result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments, ...(meta ? { _meta: meta } : {}) })) as CallToolResult;
|
|
634
646
|
} catch (err) {
|
|
635
647
|
const reason = (err as Error).message;
|
|
636
648
|
finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
|
|
@@ -663,6 +675,7 @@ export class AdtlGateway {
|
|
|
663
675
|
label: label.label,
|
|
664
676
|
...(label.ledger ? { ledger: label.ledger } : {}),
|
|
665
677
|
...(label.records ? { records: label.records } : {}),
|
|
678
|
+
...(label.recordIds ? { recordIds: label.recordIds } : {}),
|
|
666
679
|
sessionLabel: label.sessionLabel,
|
|
667
680
|
warnings: [...req.warnings, ...res.warnings],
|
|
668
681
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which session a Claude Code sub-agent belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's SubagentStart hook reports the parent's `session_id` with the
|
|
5
|
+
* new sub-agent's `agent_id`. Recording that link lets the sub-agent's later
|
|
6
|
+
* hook calls use the parent's label even if they ever report a session id of
|
|
7
|
+
* their own: the sub-agent starts from what its parent has seen, and what it
|
|
8
|
+
* reads taints the parent. Links live beside the session files, which the
|
|
9
|
+
* hooks already protect from the agent.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export interface AgentLinks {
|
|
16
|
+
/** The session `agentId` was started in, if known. */
|
|
17
|
+
parentOf(agentId: string): string | undefined;
|
|
18
|
+
link(agentId: string, sessionId: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
22
|
+
|
|
23
|
+
export class FileAgentLinks implements AgentLinks {
|
|
24
|
+
private readonly dir: string;
|
|
25
|
+
|
|
26
|
+
constructor(sessionsDir: string) {
|
|
27
|
+
this.dir = join(sessionsDir, "agents");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private pathOf(agentId: string): string {
|
|
31
|
+
const name = SAFE_ID.test(agentId) ? agentId : `h-${createHash("sha256").update(agentId).digest("hex")}`;
|
|
32
|
+
return join(this.dir, `${name}.parent`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
parentOf(agentId: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(this.pathOf(agentId), "utf8").trim() || undefined;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
link(agentId: string, sessionId: string): void {
|
|
45
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
writeFileSync(this.pathOf(agentId), sessionId, { encoding: "utf8", mode: 0o600 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class InMemoryAgentLinks implements AgentLinks {
|
|
51
|
+
private readonly links = new Map<string, string>();
|
|
52
|
+
parentOf(agentId: string): string | undefined {
|
|
53
|
+
return this.links.get(agentId);
|
|
54
|
+
}
|
|
55
|
+
link(agentId: string, sessionId: string): void {
|
|
56
|
+
this.links.set(agentId, sessionId);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* allow → no answer, so Claude Code's own permissions apply
|
|
9
9
|
* PostToolUse labels the tool's output and adds it to the session label
|
|
10
10
|
* PostToolUseFailure same: a failed tool's error output still reaches the model
|
|
11
|
+
* SubagentStart links the new sub-agent to its parent's session, so its
|
|
12
|
+
* calls start from the parent's label and taint it
|
|
11
13
|
*
|
|
12
14
|
* Built-in tools are labeled as the server `claude-code` (see preset.ts); MCP
|
|
13
15
|
* tools `mcp__<server>__<tool>` as `<server>`. Tools of the gateway's own MCP
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
*
|
|
25
27
|
* Errors fail closed: a PreToolUse hook that can't decide denies the call.
|
|
26
28
|
*/
|
|
27
|
-
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
28
30
|
import {
|
|
29
31
|
callHash,
|
|
30
32
|
evaluatePolicy,
|
|
@@ -33,11 +35,14 @@ import {
|
|
|
33
35
|
type AdtlConfig,
|
|
34
36
|
type Decision,
|
|
35
37
|
type PolicyDecision,
|
|
38
|
+
type Label,
|
|
36
39
|
type SessionStore,
|
|
37
40
|
join,
|
|
38
41
|
} from "@adtl/labels";
|
|
39
42
|
import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
|
|
40
43
|
import type { AuditEntry, AuditSink } from "../audit.js";
|
|
44
|
+
import { TOTP_KEYCHAIN_SERVICE } from "../totp.js";
|
|
45
|
+
import type { AgentLinks } from "./agentLinks.js";
|
|
41
46
|
import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
|
|
42
47
|
|
|
43
48
|
export interface HookInput {
|
|
@@ -64,8 +69,10 @@ export interface HookOptions {
|
|
|
64
69
|
audit?: AuditSink;
|
|
65
70
|
/** Label ledger shared with the gateway. */
|
|
66
71
|
ledger?: Ledger;
|
|
72
|
+
/** Sub-agent → parent session links, from SubagentStart. */
|
|
73
|
+
agents?: AgentLinks;
|
|
67
74
|
/** Files the agent must not change: the ADTL config, the session directory and the ledger. */
|
|
68
|
-
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
|
|
75
|
+
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string; totpPath?: string };
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** What the hook process prints and exits with. */
|
|
@@ -138,6 +145,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
138
145
|
if (protect.signingKeyPath && abs === resolve(protect.signingKeyPath)) {
|
|
139
146
|
return { decision: "block", reason: "The agent may not touch the ADTL signing key" };
|
|
140
147
|
}
|
|
148
|
+
if (protect.totpPath && abs.startsWith(dirname(resolve(protect.totpPath)) + sep + "totp-")) {
|
|
149
|
+
return { decision: "block", reason: "The agent may not touch the ADTL TOTP secret" };
|
|
150
|
+
}
|
|
141
151
|
}
|
|
142
152
|
const command = typeof args.command === "string" ? args.command : undefined;
|
|
143
153
|
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
@@ -155,6 +165,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
155
165
|
if (protect.signingKeyPath && command.includes(basename(protect.signingKeyPath))) {
|
|
156
166
|
return { decision: "ask", reason: "This command touches the ADTL signing key" };
|
|
157
167
|
}
|
|
168
|
+
if ([TOTP_KEYCHAIN_SERVICE, "totp-secret", "totp-last-step", protect.totpPath].some((m) => m && command.includes(m))) {
|
|
169
|
+
return { decision: "ask", reason: "This command touches the ADTL TOTP secret" };
|
|
170
|
+
}
|
|
158
171
|
}
|
|
159
172
|
return undefined;
|
|
160
173
|
}
|
|
@@ -172,19 +185,37 @@ export function addedLines(output: unknown): string[] {
|
|
|
172
185
|
return out;
|
|
173
186
|
}
|
|
174
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The session whose label a hook call reads and adds to: a linked sub-agent's
|
|
190
|
+
* parent session, else the call's own. When the two differ, the sub-agent's own
|
|
191
|
+
* label is read too, so nothing it saw is lost.
|
|
192
|
+
*/
|
|
193
|
+
function sessionFor(input: HookInput & { session_id: string }, opts: HookOptions): { id: string; get(): Label } {
|
|
194
|
+
const parent = typeof input.agent_id === "string" ? opts.agents?.parentOf(input.agent_id) : undefined;
|
|
195
|
+
if (!parent || parent === input.session_id) return { id: input.session_id, get: () => opts.sessions.get(input.session_id) };
|
|
196
|
+
return { id: parent, get: () => join(opts.sessions.get(parent), opts.sessions.get(input.session_id)) };
|
|
197
|
+
}
|
|
198
|
+
|
|
175
199
|
export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
176
200
|
const event = input.hook_event_name;
|
|
201
|
+
if (event === "SubagentStart") {
|
|
202
|
+
if (typeof input.agent_id === "string" && typeof input.session_id === "string" && input.session_id !== "") {
|
|
203
|
+
opts.agents?.link(input.agent_id, input.session_id);
|
|
204
|
+
}
|
|
205
|
+
return NOTHING;
|
|
206
|
+
}
|
|
177
207
|
if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
|
|
178
208
|
if (typeof input.tool_name !== "string" || typeof input.session_id !== "string" || input.session_id === "") {
|
|
179
209
|
return failure(event, "hook input has no tool_name or session_id");
|
|
180
210
|
}
|
|
211
|
+
const session = sessionFor(input as HookInput & { session_id: string }, opts);
|
|
181
212
|
const route = routeTool(input.tool_name, opts.gatewayServer);
|
|
182
213
|
if (!route) return NOTHING;
|
|
183
214
|
const config = withClaudeCodePreset(opts.config);
|
|
184
215
|
const ts = new Date().toISOString();
|
|
185
216
|
const agent =
|
|
186
217
|
typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
|
|
187
|
-
const base = { ts, sessionId:
|
|
218
|
+
const base = { ts, sessionId: session.id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
|
|
188
219
|
|
|
189
220
|
if (event !== "PreToolUse") {
|
|
190
221
|
const output = input.tool_response ?? input.error ?? null;
|
|
@@ -197,20 +228,20 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
197
228
|
ledger = { chunks: match.chunks, writers: match.writers };
|
|
198
229
|
}
|
|
199
230
|
// What the tool was given came from the session as it was before this output.
|
|
200
|
-
const before =
|
|
231
|
+
const before = session.get();
|
|
201
232
|
const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
|
|
202
|
-
opts.ledger.record(written, before,
|
|
233
|
+
opts.ledger.record(written, before, session.id, route);
|
|
203
234
|
}
|
|
204
|
-
const sessionLabel = opts.sessions.observe(
|
|
235
|
+
const sessionLabel = join(session.get(), opts.sessions.observe(session.id, label));
|
|
205
236
|
opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
|
|
206
237
|
return NOTHING;
|
|
207
238
|
}
|
|
208
239
|
|
|
209
240
|
const hash = callHash(route.server, route.tool, input.tool_input ?? {});
|
|
210
|
-
const
|
|
241
|
+
const current = session.get();
|
|
211
242
|
const guard = protectedTarget(input, route, opts.protect);
|
|
212
243
|
const policy: PolicyDecision = evaluatePolicy(config.policy, {
|
|
213
|
-
session,
|
|
244
|
+
session: current,
|
|
214
245
|
...route,
|
|
215
246
|
egress: toolEgress(config, route.server, route.tool),
|
|
216
247
|
});
|
|
@@ -229,7 +260,7 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
229
260
|
reason: d.reason,
|
|
230
261
|
matched: d.matched,
|
|
231
262
|
callHash: hash,
|
|
232
|
-
sessionLabel:
|
|
263
|
+
sessionLabel: current,
|
|
233
264
|
durationMs: 0,
|
|
234
265
|
};
|
|
235
266
|
opts.audit?.write(entry);
|
package/src/hooks/index.ts
CHANGED
package/src/hooks/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `adtl-gateway hook --config adtl.yaml`: the command Claude Code runs for
|
|
3
|
-
* PreToolUse, PostToolUse and
|
|
3
|
+
* PreToolUse, PostToolUse, PostToolUseFailure and SubagentStart. Reads the hook event from
|
|
4
4
|
* stdin and answers on stdout (JSON) or stderr (with exit code 2).
|
|
5
5
|
*
|
|
6
6
|
* The config needs no `gateway:` section; if there is one, its `audit` log and
|
|
@@ -13,6 +13,8 @@ import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
|
13
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
15
|
import { JsonlAuditLog } from "../audit.js";
|
|
16
|
+
import { totpSecretPath } from "../totp.js";
|
|
17
|
+
import { FileAgentLinks } from "./agentLinks.js";
|
|
16
18
|
import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
|
|
17
19
|
|
|
18
20
|
export interface RunHookOptions {
|
|
@@ -46,11 +48,12 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
46
48
|
return handleHook(input, {
|
|
47
49
|
config,
|
|
48
50
|
sessions: new FileSessionStore(sessionsDir),
|
|
51
|
+
agents: new FileAgentLinks(sessionsDir),
|
|
49
52
|
...(ledger ? { ledger } : {}),
|
|
50
53
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
51
54
|
mode: gw.mode === "audit" ? "audit" : "active",
|
|
52
55
|
...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
|
|
53
|
-
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env) },
|
|
56
|
+
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env), totpPath: totpSecretPath(env) },
|
|
54
57
|
});
|
|
55
58
|
} catch (err) {
|
|
56
59
|
return failure(input.hook_event_name, (err as Error).message);
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ export * from "./ledgerCli.js";
|
|
|
13
13
|
export * from "./auditCli.js";
|
|
14
14
|
export * from "./roles.js";
|
|
15
15
|
export * from "./cedar.js";
|
|
16
|
+
export * from "./openfga.js";
|
|
17
|
+
export * from "./totp.js";
|
|
18
|
+
export * from "./totpCli.js";
|
|
16
19
|
export * from "./elicitation.js";
|
|
17
20
|
export * from "./receipt.js";
|
|
18
21
|
export * from "./receiptCli.js";
|
package/src/init/suggest.ts
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* Without a preset, rules come from the MCP tool annotations. Those are the
|
|
5
5
|
* server's own claims, so they only decide egress; integrity is always
|
|
6
6
|
* untrusted and confidentiality private until the owner says otherwise.
|
|
7
|
+
* The draft action-metadata extension's `inputMetadata.destination`, when a
|
|
8
|
+
* tool declares one, decides egress ahead of the legacy hints.
|
|
7
9
|
* Missing hints take the MCP spec defaults (readOnlyHint false, openWorldHint true).
|
|
8
10
|
*
|
|
9
11
|
* Merging never changes an entry that is already in the file: the owner's
|
|
10
12
|
* choices win, and `init` can be re-run to pick up new tools.
|
|
11
13
|
*/
|
|
12
14
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
-
import type
|
|
15
|
+
import { destinationEgress, readActionMetadata, type ToolRule } from "@adtl/labels";
|
|
14
16
|
import { isMap, isScalar, type Document, type YAMLMap } from "yaml";
|
|
15
17
|
import { choosePreset, PRESETS, type Preset } from "./presets.js";
|
|
16
18
|
|
|
@@ -40,6 +42,13 @@ const UNKNOWN_DEFAULTS: ToolRule = { integrity: "untrusted", confidentiality: "p
|
|
|
40
42
|
|
|
41
43
|
/** A rule from the tool's MCP annotations alone. */
|
|
42
44
|
export function ruleFromHints(annotations: Tool["annotations"]): Omit<ToolSuggestion, "name"> {
|
|
45
|
+
const meta = readActionMetadata(annotations);
|
|
46
|
+
const declared = destinationEgress(meta.destination);
|
|
47
|
+
if (declared) {
|
|
48
|
+
const extra = [meta.outcome, meta.requiresReview ? "requires review" : undefined].filter(Boolean).join(", ");
|
|
49
|
+
const why = `action-metadata: destination ${meta.destination}${extra ? `, ${extra}` : ""} (review)`;
|
|
50
|
+
return { rule: { integrity: "untrusted", confidentiality: "private", egress: declared }, why };
|
|
51
|
+
}
|
|
43
52
|
const readOnly = annotations?.readOnlyHint === true;
|
|
44
53
|
const openWorld = annotations?.openWorldHint !== false;
|
|
45
54
|
const egress = readOnly ? (openWorld ? "external" : "none") : openWorld ? "public" : "internal";
|