@adtl/gateway 0.5.0 → 0.6.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 +105 -12
- package/dist/approvals.d.ts +22 -6
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +56 -26
- package/dist/approvals.js.map +1 -1
- package/dist/audit.d.ts +8 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/auditCli.d.ts +2 -0
- package/dist/auditCli.d.ts.map +1 -1
- package/dist/auditCli.js +13 -10
- package/dist/auditCli.js.map +1 -1
- package/dist/cli.js +17 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +285 -21
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +49 -1
- package/dist/config.js.map +1 -1
- package/dist/elicitation.d.ts +25 -0
- package/dist/elicitation.d.ts.map +1 -0
- package/dist/elicitation.js +46 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/gateway.d.ts +26 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +85 -8
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +4 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +8 -1
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +6 -3
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/init/presets.d.ts.map +1 -1
- package/dist/init/presets.js +54 -0
- package/dist/init/presets.js.map +1 -1
- package/dist/invoker.d.ts +21 -1
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +19 -2
- package/dist/invoker.js.map +1 -1
- package/dist/ledgerCli.d.ts +1 -1
- package/dist/ledgerCli.d.ts.map +1 -1
- package/dist/ledgerCli.js +9 -3
- package/dist/ledgerCli.js.map +1 -1
- package/dist/ntfy.d.ts +3 -1
- package/dist/ntfy.d.ts.map +1 -1
- package/dist/ntfy.js +2 -1
- package/dist/ntfy.js.map +1 -1
- package/dist/playgroundCli.d.ts +8 -0
- package/dist/playgroundCli.d.ts.map +1 -0
- package/dist/playgroundCli.js +57 -0
- package/dist/playgroundCli.js.map +1 -0
- package/dist/receipt.d.ts +62 -0
- package/dist/receipt.d.ts.map +1 -0
- package/dist/receipt.js +117 -0
- package/dist/receipt.js.map +1 -0
- package/dist/receiptCli.d.ts +8 -0
- package/dist/receiptCli.d.ts.map +1 -0
- package/dist/receiptCli.js +87 -0
- package/dist/receiptCli.js.map +1 -0
- package/dist/roles.d.ts +9 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +41 -0
- package/dist/roles.js.map +1 -0
- package/dist/startup.d.ts +9 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +69 -7
- package/dist/startup.js.map +1 -1
- package/package.json +5 -4
- package/playground/fake-github.mjs +98 -0
- package/playground/fake-memory.mjs +130 -0
- package/playground/index.html +411 -0
- package/playground/playground.yaml +40 -0
- package/playground/server.mjs +290 -0
- package/src/approvals.ts +63 -26
- package/src/audit.ts +5 -0
- package/src/auditCli.ts +11 -8
- package/src/cli.ts +16 -1
- package/src/config.ts +54 -1
- package/src/elicitation.ts +69 -0
- package/src/gateway.ts +96 -9
- package/src/hooks/claudeCode.ts +13 -2
- package/src/hooks/run.ts +6 -3
- package/src/index.ts +5 -0
- package/src/init/presets.ts +54 -0
- package/src/invoker.ts +39 -2
- package/src/ledgerCli.ts +9 -3
- package/src/ntfy.ts +4 -1
- package/src/playgroundCli.ts +60 -0
- package/src/receipt.ts +148 -0
- package/src/receiptCli.ts +85 -0
- package/src/roles.ts +55 -0
- package/src/startup.ts +68 -6
package/src/config.ts
CHANGED
|
@@ -12,15 +12,21 @@
|
|
|
12
12
|
* docs:
|
|
13
13
|
* url: https://example.com/mcp
|
|
14
14
|
* headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
|
|
15
|
+
* principal: { id: "${USER}" } # optional: who runs this gateway (default: anonymous)
|
|
16
|
+
* roles: # optional: who may use which tools (deny by default)
|
|
17
|
+
* maintainers: { members: [alice], tools: ["github.*"] }
|
|
18
|
+
* readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
|
|
15
19
|
* approvals: # optional: make "ask" really ask
|
|
16
20
|
* timeoutSeconds: 50
|
|
17
21
|
* ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
|
|
22
|
+
* approvers: # optional: each approver's own channel (policy rule `approvers:`)
|
|
23
|
+
* alice: { ntfy: { topic: "${ALICE_NTFY_TOPIC}" } }
|
|
18
24
|
* email:
|
|
19
25
|
* smtp: { host: smtp.example.com, port: 465, user: "${SMTP_USER}", pass: "${SMTP_PASS}" }
|
|
20
26
|
* from: adtl@example.com
|
|
21
27
|
* to: you@example.com
|
|
22
28
|
*
|
|
23
|
-
* `${VAR}` in command, args, env, cwd, url, headers
|
|
29
|
+
* `${VAR}` in command, args, env, cwd, url, headers, approval settings and principal.id is
|
|
24
30
|
* replaced from the gateway's environment. A missing variable is a config error,
|
|
25
31
|
* unless a default is given: `${VAR:-default}`.
|
|
26
32
|
*/
|
|
@@ -88,10 +94,50 @@ export const ApprovalsConfigSchema = z
|
|
|
88
94
|
timeoutSeconds: z.number().int().positive().max(3600).default(50),
|
|
89
95
|
ntfy: NtfyConfigSchema.optional(),
|
|
90
96
|
email: EmailConfigSchema.optional(),
|
|
97
|
+
/**
|
|
98
|
+
* Also ask in the MCP client (elicitation), when it supports it. Off by
|
|
99
|
+
* default: a client that accepts elicitations by itself would approve calls.
|
|
100
|
+
*/
|
|
101
|
+
elicitation: z.boolean().default(false),
|
|
102
|
+
/**
|
|
103
|
+
* Each approver's own channel, by principal id. Policy rules with
|
|
104
|
+
* `approvers: [role]` ask only these people, so the answer names who approved.
|
|
105
|
+
*/
|
|
106
|
+
approvers: z.record(z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
|
|
91
107
|
})
|
|
92
108
|
.strict();
|
|
93
109
|
export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
|
|
94
110
|
|
|
111
|
+
/** `server.tool` (or `server.prompt`), with `*` wildcards, as in policy rules. */
|
|
112
|
+
const QualifiedPattern = z
|
|
113
|
+
.string()
|
|
114
|
+
.min(1)
|
|
115
|
+
.refine((p) => /^[^.]+\..+$/.test(p), { message: 'Use "server.name" with optional * wildcards, e.g. "github.read_*" or "github.*"' });
|
|
116
|
+
|
|
117
|
+
export const PrincipalConfigSchema = z
|
|
118
|
+
.object({
|
|
119
|
+
type: z.enum(["user", "service"]).default("user"),
|
|
120
|
+
id: z.string().min(1),
|
|
121
|
+
claims: z.record(z.unknown()).optional(),
|
|
122
|
+
})
|
|
123
|
+
.strict();
|
|
124
|
+
export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
|
|
125
|
+
|
|
126
|
+
export const RoleSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
/** Principal ids; "*" means everyone, including an anonymous caller. */
|
|
129
|
+
members: z.array(z.string().min(1)).nonempty("A role needs at least one member (\"*\" for everyone)"),
|
|
130
|
+
tools: z.array(QualifiedPattern).default([]),
|
|
131
|
+
prompts: z.array(QualifiedPattern).default([]),
|
|
132
|
+
/** Resource URI patterns, e.g. "github://acme/*". */
|
|
133
|
+
resources: z.array(z.string().min(1)).default([]),
|
|
134
|
+
})
|
|
135
|
+
.strict()
|
|
136
|
+
.refine((r) => r.tools.length + r.prompts.length + r.resources.length > 0, {
|
|
137
|
+
message: "A role must grant at least one of tools, prompts or resources",
|
|
138
|
+
});
|
|
139
|
+
export type RoleConfig = z.infer<typeof RoleSchema>;
|
|
140
|
+
|
|
95
141
|
export const GatewayConfigSchema = z
|
|
96
142
|
.object({
|
|
97
143
|
upstreams: z
|
|
@@ -113,6 +159,13 @@ export const GatewayConfigSchema = z
|
|
|
113
159
|
approvals: ApprovalsConfigSchema.optional(),
|
|
114
160
|
/** Timeout for each interceptor invocation, in milliseconds. */
|
|
115
161
|
interceptorTimeoutMs: z.number().int().positive().default(5000),
|
|
162
|
+
/** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
|
|
163
|
+
principal: PrincipalConfigSchema.optional(),
|
|
164
|
+
/** Role-based authorization: when present, a call no role grants is denied. */
|
|
165
|
+
roles: z
|
|
166
|
+
.record(RoleSchema)
|
|
167
|
+
.refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
|
|
168
|
+
.optional(),
|
|
116
169
|
})
|
|
117
170
|
.strict();
|
|
118
171
|
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approvals inside the MCP client, through `elicitation/create`: the person
|
|
3
|
+
* using the client (for example Claude Code) sees the request and answers
|
|
4
|
+
* Allow or Deny there, no phone needed.
|
|
5
|
+
*
|
|
6
|
+
* The answer is turned into the same signed token a push button would send,
|
|
7
|
+
* so it answers only the one pending call it was asked about. Declining or
|
|
8
|
+
* dismissing the prompt counts as Deny. The gateway uses this channel only
|
|
9
|
+
* when `gateway.approvals.elicitation` is on and the client declares the
|
|
10
|
+
* elicitation capability, and never for rules with named approvers: the
|
|
11
|
+
* client can't say who answered.
|
|
12
|
+
*/
|
|
13
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
14
|
+
import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
|
|
16
|
+
|
|
17
|
+
interface Listening {
|
|
18
|
+
answer: AnswerFn;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True if the connected client can answer elicitation requests. */
|
|
23
|
+
export function supportsElicitation(server: Server): boolean {
|
|
24
|
+
return server.getClientCapabilities()?.elicitation !== undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function message(request: ApprovalRequest): string {
|
|
28
|
+
const rule = request.matched.join(", ") || "default";
|
|
29
|
+
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
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ElicitationChannel implements Notifier {
|
|
33
|
+
readonly name = "elicitation";
|
|
34
|
+
private readonly listening = new Map<string, Listening>();
|
|
35
|
+
|
|
36
|
+
constructor(private readonly server: Server) {}
|
|
37
|
+
|
|
38
|
+
async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
|
|
39
|
+
this.listening.set(request.id, { answer, signal });
|
|
40
|
+
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
41
|
+
this.listening.delete(request.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async notify(event: ApprovalEvent): Promise<void> {
|
|
45
|
+
if (event.kind !== "pending") return; // the client sees the call's result; no other notices
|
|
46
|
+
const l = this.listening.get(event.request.id);
|
|
47
|
+
if (!l) throw new Error("not listening for this request");
|
|
48
|
+
// A plain request, not Server.elicitInput(): clients that declare only `elicitation: {}` support forms too.
|
|
49
|
+
const result = await this.server.request(
|
|
50
|
+
{
|
|
51
|
+
method: "elicitation/create",
|
|
52
|
+
params: {
|
|
53
|
+
message: message(event.request),
|
|
54
|
+
requestedSchema: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" },
|
|
58
|
+
},
|
|
59
|
+
required: ["decision"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
ElicitResultSchema,
|
|
64
|
+
{ signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
|
|
65
|
+
);
|
|
66
|
+
const allow = result.action === "accept" && result.content?.decision === "allow";
|
|
67
|
+
l.answer(allow ? event.tokens.allow : event.tokens.deny);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/gateway.ts
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
*
|
|
24
24
|
* An optional Authorizer decides first whether the caller may use the tool,
|
|
25
25
|
* resource or prompt at all; a denial stops the call before the policy runs.
|
|
26
|
+
* The lists the agent sees leave out what the authorizer would deny, so the
|
|
27
|
+
* agent doesn't try it; hiding is a convenience, and every call is still checked.
|
|
26
28
|
*
|
|
27
29
|
* In audit mode (the invoker runs every interceptor with `mode: "audit"`)
|
|
28
30
|
* nothing is stopped or changed: calls the policy would stop are forwarded,
|
|
@@ -54,12 +56,13 @@ import {
|
|
|
54
56
|
import type { InvocationContext } from "@adtl/interceptors";
|
|
55
57
|
import { callBindingHash } from "@adtl/labels";
|
|
56
58
|
import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
|
|
59
|
+
import { ElicitationChannel, supportsElicitation } from "./elicitation.js";
|
|
57
60
|
import type { AuditEntry, AuditSink } from "./audit.js";
|
|
58
61
|
import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
|
|
59
62
|
import { TOOL_SEPARATOR } from "./config.js";
|
|
60
|
-
import type { Blocked,
|
|
63
|
+
import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
|
|
61
64
|
|
|
62
|
-
export const GATEWAY_VERSION = "0.
|
|
65
|
+
export const GATEWAY_VERSION = "0.6.0";
|
|
63
66
|
|
|
64
67
|
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
65
68
|
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
@@ -108,6 +111,20 @@ export interface AdtlGatewayOptions {
|
|
|
108
111
|
authorizer?: Authorizer;
|
|
109
112
|
/** The caller, passed to the authorizer and the interceptors. Default: anonymous. */
|
|
110
113
|
principal?: Principal;
|
|
114
|
+
/** Members of each role that policy rules name as `approvers:`. */
|
|
115
|
+
approverRoles?: Map<string, string[]>;
|
|
116
|
+
/** Also ask for approval in the MCP client, when it supports elicitation. */
|
|
117
|
+
elicitApprovals?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Who may answer an "ask" whose rules name approver roles: someone in one of
|
|
122
|
+
* the roles of every list. Sorted, for stable notifications and tests.
|
|
123
|
+
*/
|
|
124
|
+
export function eligibleApprovers(lists: string[][], roles: Map<string, string[]>): string[] {
|
|
125
|
+
const sets = lists.map((list) => new Set(list.flatMap((role) => roles.get(role) ?? [])));
|
|
126
|
+
const [first, ...rest] = sets;
|
|
127
|
+
return [...(first ?? [])].filter((id) => id !== "*" && rest.every((s) => s.has(id))).sort();
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
export interface CallOptions {
|
|
@@ -157,6 +174,10 @@ export class AdtlGateway {
|
|
|
157
174
|
private readonly approvals: ApprovalBroker | undefined;
|
|
158
175
|
private readonly authorizer: Authorizer | undefined;
|
|
159
176
|
private readonly principal: Principal;
|
|
177
|
+
private readonly approverRoles: Map<string, string[]>;
|
|
178
|
+
private readonly elicitApprovals: boolean;
|
|
179
|
+
/** The server the agent is connected to, for elicitation. */
|
|
180
|
+
private server: Server | undefined;
|
|
160
181
|
private routes = new Map<string, Route>();
|
|
161
182
|
private promptRoutes = new Map<string, PromptRoute>();
|
|
162
183
|
/** Resource URI → the upstreams that list it. */
|
|
@@ -172,6 +193,8 @@ export class AdtlGateway {
|
|
|
172
193
|
this.approvals = opts.approvals;
|
|
173
194
|
this.authorizer = opts.authorizer;
|
|
174
195
|
this.principal = opts.principal ?? { type: "anonymous" };
|
|
196
|
+
this.approverRoles = opts.approverRoles ?? new Map();
|
|
197
|
+
this.elicitApprovals = opts.elicitApprovals === true;
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
/** Ask the authorizer, if any. Returns the denial; in audit mode the caller logs it and carries on. */
|
|
@@ -195,7 +218,7 @@ export class AdtlGateway {
|
|
|
195
218
|
"if a call is stopped, the result explains why.",
|
|
196
219
|
},
|
|
197
220
|
);
|
|
198
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.
|
|
221
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.visibleTools() }));
|
|
199
222
|
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
200
223
|
const progressToken = req.params._meta?.progressToken;
|
|
201
224
|
const progress =
|
|
@@ -206,16 +229,17 @@ export class AdtlGateway {
|
|
|
206
229
|
return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
|
|
207
230
|
});
|
|
208
231
|
if (capabilities.resources) {
|
|
209
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.
|
|
232
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.visibleResources() }));
|
|
210
233
|
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
|
211
234
|
resourceTemplates: await this.listResourceTemplates(),
|
|
212
235
|
}));
|
|
213
236
|
server.setRequestHandler(ReadResourceRequestSchema, async (req) => this.readResource(req.params.uri));
|
|
214
237
|
}
|
|
215
238
|
if (capabilities.prompts) {
|
|
216
|
-
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.
|
|
239
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.visiblePrompts() }));
|
|
217
240
|
server.setRequestHandler(GetPromptRequestSchema, async (req) => this.getPrompt(req.params.name, req.params.arguments));
|
|
218
241
|
}
|
|
242
|
+
this.server = server;
|
|
219
243
|
return server;
|
|
220
244
|
}
|
|
221
245
|
|
|
@@ -242,6 +266,49 @@ export class AdtlGateway {
|
|
|
242
266
|
return out;
|
|
243
267
|
}
|
|
244
268
|
|
|
269
|
+
/** tools/list as the caller sees it: without the tools the authorizer would deny. */
|
|
270
|
+
async visibleTools(): Promise<Tool[]> {
|
|
271
|
+
const tools = await this.listTools();
|
|
272
|
+
return this.hideDenied(tools, (t) => {
|
|
273
|
+
const r = this.routes.get(t.name)!;
|
|
274
|
+
return { event: "tools/call", server: r.upstream, tool: r.tool };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** prompts/list as the caller sees it. */
|
|
279
|
+
async visiblePrompts(): Promise<Prompt[]> {
|
|
280
|
+
const prompts = await this.listPrompts();
|
|
281
|
+
return this.hideDenied(prompts, (p) => {
|
|
282
|
+
const r = this.promptRoutes.get(p.name)!;
|
|
283
|
+
return { event: "prompts/get", server: r.upstream, prompt: r.prompt };
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** resources/list as the caller sees it. A URI several upstreams offer is listed as before (reading it is refused). */
|
|
288
|
+
async visibleResources(): Promise<Resource[]> {
|
|
289
|
+
const resources = await this.listResources();
|
|
290
|
+
return this.hideDenied(resources, (r) => {
|
|
291
|
+
const ups = this.resourceRoutes.get(r.uri);
|
|
292
|
+
return ups?.size === 1 ? { event: "resources/read", server: [...ups][0]!, resource: r.uri } : undefined;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Leave out items the authorizer would deny, asked without arguments. Not in
|
|
298
|
+
* audit mode, which changes nothing. Hiding only saves the agent a denied
|
|
299
|
+
* call: routes are kept, and a call to a hidden item is still authorized.
|
|
300
|
+
*/
|
|
301
|
+
private async hideDenied<T>(items: T[], request: (item: T) => Omit<AuthorizationRequest, "principal"> | undefined): Promise<T[]> {
|
|
302
|
+
if (!this.authorizer || this.invoker.modeOverride === "audit") return items;
|
|
303
|
+
const shown = await Promise.all(
|
|
304
|
+
items.map(async (item) => {
|
|
305
|
+
const r = request(item);
|
|
306
|
+
return r === undefined || (await this.authorize(r)) === undefined;
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
return items.filter((_, i) => shown[i]);
|
|
310
|
+
}
|
|
311
|
+
|
|
245
312
|
async listTools(): Promise<Tool[]> {
|
|
246
313
|
const all = await this.listAll("tools/list", "tools", async (c, cursor) => {
|
|
247
314
|
const p = await c.listTools(cursor ? { cursor } : {});
|
|
@@ -374,6 +441,7 @@ export class AdtlGateway {
|
|
|
374
441
|
const config = { server: upstream };
|
|
375
442
|
const audit = this.invoker.modeOverride === "audit" ? { mode: "audit" as const } : {};
|
|
376
443
|
let denied: Denial | undefined;
|
|
444
|
+
const ran: InterceptorRun[] = [];
|
|
377
445
|
const finish = (entry: Pick<AuditEntry, "outcome"> & Partial<AuditEntry>) =>
|
|
378
446
|
this.audit?.write({
|
|
379
447
|
ts: context.timestamp!,
|
|
@@ -383,6 +451,7 @@ export class AdtlGateway {
|
|
|
383
451
|
...target,
|
|
384
452
|
...(denied ? { denied } : {}),
|
|
385
453
|
...entry,
|
|
454
|
+
...(ran.length ? { interceptors: [...ran] } : {}),
|
|
386
455
|
durationMs: Math.round(performance.now() - started),
|
|
387
456
|
});
|
|
388
457
|
const what = "resource" in target ? `resource ${target.resource}` : `prompt ${upstream}${TOOL_SEPARATOR}${target.prompt}`;
|
|
@@ -401,6 +470,7 @@ export class AdtlGateway {
|
|
|
401
470
|
if (denied) this.log.info(`audit: would deny ${what}: ${denied.reason}`);
|
|
402
471
|
|
|
403
472
|
const req = await this.invoker.runRequest({ event, payload: { method: event, params }, config, context });
|
|
473
|
+
ran.push(...req.ran);
|
|
404
474
|
if (req.blocked) {
|
|
405
475
|
finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, warnings: req.warnings });
|
|
406
476
|
this.log.info(`${req.blocked.decision} ${what}: ${req.blocked.reason}`);
|
|
@@ -417,6 +487,7 @@ export class AdtlGateway {
|
|
|
417
487
|
}
|
|
418
488
|
|
|
419
489
|
const res = await this.invoker.runResponse({ event, payload: { method: event, params: forwarded, result }, config, context });
|
|
490
|
+
ran.push(...res.ran);
|
|
420
491
|
const warnings = [...req.warnings, ...res.warnings];
|
|
421
492
|
if (res.blocked) {
|
|
422
493
|
// Fail closed: unlabeled content must not reach the agent.
|
|
@@ -452,8 +523,16 @@ export class AdtlGateway {
|
|
|
452
523
|
};
|
|
453
524
|
const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
454
525
|
let denied: Denial | undefined;
|
|
526
|
+
const ran: InterceptorRun[] = [];
|
|
455
527
|
const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
|
|
456
|
-
this.audit?.write({
|
|
528
|
+
this.audit?.write({
|
|
529
|
+
ts: context.timestamp!,
|
|
530
|
+
...base,
|
|
531
|
+
...(denied ? { denied } : {}),
|
|
532
|
+
...entry,
|
|
533
|
+
...(ran.length ? { interceptors: [...ran] } : {}),
|
|
534
|
+
durationMs: Math.round(performance.now() - started),
|
|
535
|
+
});
|
|
457
536
|
|
|
458
537
|
// 0. Authorization: may this caller use this tool at all? A denial wins over any policy decision.
|
|
459
538
|
denied = await this.authorize({ event: "tools/call", server: route.upstream, tool: route.tool, arguments: args });
|
|
@@ -472,6 +551,7 @@ export class AdtlGateway {
|
|
|
472
551
|
// 1. Request phase: adtl.policy decides whether the call may run.
|
|
473
552
|
const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
|
|
474
553
|
let req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
|
|
554
|
+
ran.push(...req.ran);
|
|
475
555
|
const policy = infoOf(req, "adtl.policy");
|
|
476
556
|
let approval: ApprovalResult | undefined;
|
|
477
557
|
if (req.blocked) {
|
|
@@ -481,8 +561,12 @@ export class AdtlGateway {
|
|
|
481
561
|
const stopped = { decision: b.decision, reason: b.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel };
|
|
482
562
|
const matched = Array.isArray(policy.matched) ? (policy.matched as string[]) : [];
|
|
483
563
|
const hash = b.confirmation?.callHash ?? policy.callHash;
|
|
484
|
-
|
|
485
|
-
|
|
564
|
+
const lists = Array.isArray(policy.approvers) ? (policy.approvers as string[][]) : undefined;
|
|
565
|
+
const approvers = lists ? eligibleApprovers(lists, this.approverRoles) : undefined;
|
|
566
|
+
// The client's own prompt, if enabled and supported; never for named approvers (it can't say who answered).
|
|
567
|
+
const elicit = this.elicitApprovals && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server)] : [];
|
|
568
|
+
if (b.decision === "ask" && this.approvals && (this.approvals.canApprove || elicit.length > 0) && typeof hash === "string") {
|
|
569
|
+
this.log.info(`ask ${name}: waiting for approval${approvers ? ` from ${approvers.join(", ") || "nobody"}` : ""} (${b.reason})`);
|
|
486
570
|
approval = await this.waitForApproval(opts, () =>
|
|
487
571
|
this.approvals!.request({
|
|
488
572
|
sessionId: this.sessionId,
|
|
@@ -492,7 +576,8 @@ export class AdtlGateway {
|
|
|
492
576
|
reason: b.reason,
|
|
493
577
|
matched,
|
|
494
578
|
...(policy.sessionLabel !== undefined ? { sessionLabel: policy.sessionLabel } : {}),
|
|
495
|
-
|
|
579
|
+
...(approvers ? { approvers } : {}),
|
|
580
|
+
}, elicit),
|
|
496
581
|
);
|
|
497
582
|
this.log.info(`ask ${name}: ${approval.outcome}`);
|
|
498
583
|
if (approval.outcome !== "approved") {
|
|
@@ -511,6 +596,7 @@ export class AdtlGateway {
|
|
|
511
596
|
...(approval.approver ? { principal: { type: "user" as const, id: approval.approver } } : {}),
|
|
512
597
|
}];
|
|
513
598
|
req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context: { ...context, confirmations } });
|
|
599
|
+
ran.push(...req.ran);
|
|
514
600
|
const sent = callBindingHash({ event: "tools/call", phase: "request", target: route.upstream, payload: req.payload });
|
|
515
601
|
const recheck = req.blocked?.reason ?? (sent !== c.callHash ? "the call changed after it was approved" : undefined);
|
|
516
602
|
if (recheck !== undefined) {
|
|
@@ -554,6 +640,7 @@ export class AdtlGateway {
|
|
|
554
640
|
config,
|
|
555
641
|
context,
|
|
556
642
|
});
|
|
643
|
+
ran.push(...res.ran);
|
|
557
644
|
if (res.blocked) {
|
|
558
645
|
// Fail closed: an unlabeled result must not reach the agent.
|
|
559
646
|
finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, callHash: policy.callHash, warnings: [...req.warnings, ...res.warnings] });
|
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface HookInput {
|
|
|
49
49
|
/** PostToolUseFailure: the error the tool returned. */
|
|
50
50
|
error?: unknown;
|
|
51
51
|
cwd?: string;
|
|
52
|
+
/** Set when the hook fires inside a subagent (or an in-process teammate). */
|
|
53
|
+
agent_id?: string;
|
|
54
|
+
agent_type?: string;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
export interface HookOptions {
|
|
@@ -62,7 +65,7 @@ export interface HookOptions {
|
|
|
62
65
|
/** Label ledger shared with the gateway. */
|
|
63
66
|
ledger?: Ledger;
|
|
64
67
|
/** Files the agent must not change: the ADTL config, the session directory and the ledger. */
|
|
65
|
-
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string };
|
|
68
|
+
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/** What the hook process prints and exits with. */
|
|
@@ -132,6 +135,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
132
135
|
if (protect.auditPath && abs.startsWith(resolve(protect.auditPath))) {
|
|
133
136
|
return { decision: "block", reason: "The agent may not change the ADTL audit log" };
|
|
134
137
|
}
|
|
138
|
+
if (protect.signingKeyPath && abs === resolve(protect.signingKeyPath)) {
|
|
139
|
+
return { decision: "block", reason: "The agent may not touch the ADTL signing key" };
|
|
140
|
+
}
|
|
135
141
|
}
|
|
136
142
|
const command = typeof args.command === "string" ? args.command : undefined;
|
|
137
143
|
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
@@ -146,6 +152,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
146
152
|
if (protect.auditPath && command.includes(basename(protect.auditPath))) {
|
|
147
153
|
return { decision: "ask", reason: "This command touches the ADTL audit log" };
|
|
148
154
|
}
|
|
155
|
+
if (protect.signingKeyPath && command.includes(basename(protect.signingKeyPath))) {
|
|
156
|
+
return { decision: "ask", reason: "This command touches the ADTL signing key" };
|
|
157
|
+
}
|
|
149
158
|
}
|
|
150
159
|
return undefined;
|
|
151
160
|
}
|
|
@@ -173,7 +182,9 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
173
182
|
if (!route) return NOTHING;
|
|
174
183
|
const config = withClaudeCodePreset(opts.config);
|
|
175
184
|
const ts = new Date().toISOString();
|
|
176
|
-
const
|
|
185
|
+
const agent =
|
|
186
|
+
typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
|
|
187
|
+
const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
|
|
177
188
|
|
|
178
189
|
if (event !== "PreToolUse") {
|
|
179
190
|
const output = input.tool_response ?? input.error ?? null;
|
package/src/hooks/run.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
|
-
import { FileSessionStore, defaultSessionsDir } from "@adtl/labels";
|
|
11
|
+
import { FileSessionStore, defaultSessionsDir, defaultSigningKeyPath, trySigner } from "@adtl/labels";
|
|
12
12
|
import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
13
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
@@ -38,7 +38,10 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
38
38
|
const env = opts.env ?? process.env;
|
|
39
39
|
const sessionsDir = defaultSessionsDir(env);
|
|
40
40
|
const ledgerPath = config.ledger.path ? resolve(dirname(path), config.ledger.path) : defaultLedgerPath(env);
|
|
41
|
-
const
|
|
41
|
+
const signer = config.ledger.enabled ? trySigner(defaultSigningKeyPath(env)) : undefined;
|
|
42
|
+
const ledger = config.ledger.enabled
|
|
43
|
+
? new Ledger(ledgerPath, { retentionDays: config.ledger.retentionDays, ...(signer ? { signer } : {}) })
|
|
44
|
+
: undefined;
|
|
42
45
|
const auditPath = typeof gw.audit === "string" ? resolve(dirname(path), gw.audit) : undefined;
|
|
43
46
|
return handleHook(input, {
|
|
44
47
|
config,
|
|
@@ -47,7 +50,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
47
50
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
48
51
|
mode: gw.mode === "audit" ? "audit" : "active",
|
|
49
52
|
...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
|
|
50
|
-
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}) },
|
|
53
|
+
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env) },
|
|
51
54
|
});
|
|
52
55
|
} catch (err) {
|
|
53
56
|
return failure(input.hook_event_name, (err as Error).message);
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,8 @@ export * from "./init/index.js";
|
|
|
11
11
|
export * from "./hooks/index.js";
|
|
12
12
|
export * from "./ledgerCli.js";
|
|
13
13
|
export * from "./auditCli.js";
|
|
14
|
+
export * from "./roles.js";
|
|
15
|
+
export * from "./elicitation.js";
|
|
16
|
+
export * from "./receipt.js";
|
|
17
|
+
export * from "./receiptCli.js";
|
|
18
|
+
export * from "./playgroundCli.js";
|
package/src/init/presets.ts
CHANGED
|
@@ -53,6 +53,60 @@ export const PRESETS: Preset[] = [
|
|
|
53
53
|
write: { integrity: "untrusted", confidentiality: "public", egress: "external" },
|
|
54
54
|
note: "preset fetch: web pages are untrusted; a fetched URL can carry data out, so fetch is external egress",
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
id: "slack",
|
|
58
|
+
// Both popular servers report this name: @modelcontextprotocol/server-slack (no tool hints, so its
|
|
59
|
+
// tools are listed below) and korotovsky/slack-mcp-server (marks reads with readOnlyHint).
|
|
60
|
+
serverNames: ["Slack MCP Server"],
|
|
61
|
+
// Anyone in the workspace, or a shared channel, can write messages and profiles.
|
|
62
|
+
defaults: { integrity: "untrusted", confidentiality: "private", egress: "external" },
|
|
63
|
+
read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
64
|
+
write: { egress: "external" },
|
|
65
|
+
tools: {
|
|
66
|
+
slack_list_channels: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
67
|
+
slack_get_channel_history: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
68
|
+
slack_get_thread_replies: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
69
|
+
slack_get_users: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
70
|
+
slack_get_user_profile: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
71
|
+
slack_post_message: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
72
|
+
slack_reply_to_thread: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
73
|
+
slack_add_reaction: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
74
|
+
},
|
|
75
|
+
note: "preset slack: messages are untrusted (anyone in the workspace can write them) and private; posting is external egress (set public for channels with outside guests)",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "gmail",
|
|
79
|
+
// @gongrzhe/server-gmail-autoauth-mcp, the most used Gmail server. It sets no tool hints.
|
|
80
|
+
serverNames: ["gmail"],
|
|
81
|
+
// Anyone can send you email.
|
|
82
|
+
defaults: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
83
|
+
read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
84
|
+
write: { egress: "internal" },
|
|
85
|
+
tools: {
|
|
86
|
+
read_email: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
87
|
+
search_emails: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
88
|
+
list_email_labels: { integrity: "untrusted", confidentiality: "private", egress: "none" },
|
|
89
|
+
list_filters: { integrity: "trusted", confidentiality: "private", egress: "none" },
|
|
90
|
+
get_filter: { integrity: "trusted", confidentiality: "private", egress: "none" },
|
|
91
|
+
// Saves the attachment to a local folder.
|
|
92
|
+
download_attachment: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
93
|
+
send_email: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
94
|
+
draft_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
95
|
+
modify_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
96
|
+
delete_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
97
|
+
batch_modify_emails: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
98
|
+
batch_delete_emails: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
99
|
+
create_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
100
|
+
update_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
101
|
+
delete_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
102
|
+
get_or_create_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
103
|
+
delete_filter: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
104
|
+
// Filters can forward matching mail to any address.
|
|
105
|
+
create_filter: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
106
|
+
create_filter_from_template: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
107
|
+
},
|
|
108
|
+
note: "preset gmail: email is untrusted (anyone can send it) and private; sending, and filters that can forward mail, are external egress",
|
|
109
|
+
},
|
|
56
110
|
];
|
|
57
111
|
|
|
58
112
|
/** The preset for an upstream: by the name the server reports, then by the upstream id. */
|
package/src/invoker.ts
CHANGED
|
@@ -55,12 +55,31 @@ export interface Blocked {
|
|
|
55
55
|
confirmation?: ConfirmationRequest;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* One interceptor invocation, with the execution policy it ran under. SEP-2624
|
|
60
|
+
* (Security Best Practice 3) asks invokers to log the resolved `mode` and
|
|
61
|
+
* `failOpen`, and whether each came from an invoker override or the
|
|
62
|
+
* interceptor's declared default.
|
|
63
|
+
*/
|
|
64
|
+
export interface InterceptorRun {
|
|
65
|
+
name: string;
|
|
66
|
+
phase: Phase;
|
|
67
|
+
mode: InterceptorMode;
|
|
68
|
+
modeFrom: "override" | "default";
|
|
69
|
+
failOpen: boolean;
|
|
70
|
+
failOpenFrom: "override" | "default";
|
|
71
|
+
/** Set when the invocation errored or timed out. */
|
|
72
|
+
failed?: true;
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
export interface PhaseOutcome {
|
|
59
76
|
payload: unknown;
|
|
60
77
|
blocked?: Blocked;
|
|
61
78
|
results: InterceptorResult[];
|
|
62
79
|
/** Interceptors that failed but were allowed to (failOpen or audit mode). */
|
|
63
80
|
warnings: string[];
|
|
81
|
+
/** Every interceptor invoked in this phase. */
|
|
82
|
+
ran: InterceptorRun[];
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
export interface InvokeOptions {
|
|
@@ -101,14 +120,14 @@ export class InterceptorInvoker {
|
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
async runRequest(opts: InvokeOptions): Promise<PhaseOutcome> {
|
|
104
|
-
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
123
|
+
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [], ran: [] };
|
|
105
124
|
if (!(await this.mutate("request", opts, out))) return out;
|
|
106
125
|
await this.validate("request", opts, out);
|
|
107
126
|
return out;
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
async runResponse(opts: InvokeOptions): Promise<PhaseOutcome> {
|
|
111
|
-
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
130
|
+
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [], ran: [] };
|
|
112
131
|
if (!(await this.validate("response", opts, out))) return out;
|
|
113
132
|
await this.mutate("response", opts, out);
|
|
114
133
|
return out;
|
|
@@ -118,6 +137,20 @@ export class InterceptorInvoker {
|
|
|
118
137
|
return (this.modeOverride ?? i.definition.mode) === "audit";
|
|
119
138
|
}
|
|
120
139
|
|
|
140
|
+
/** Record that `i` runs in `phase`, with its resolved policy. The gateway has no failOpen overrides. */
|
|
141
|
+
private run(i: BoundInterceptor, phase: Phase, out: PhaseOutcome): InterceptorRun {
|
|
142
|
+
const r: InterceptorRun = {
|
|
143
|
+
name: i.definition.name,
|
|
144
|
+
phase,
|
|
145
|
+
mode: this.modeOverride ?? i.definition.mode ?? "active",
|
|
146
|
+
modeFrom: this.modeOverride ? "override" : "default",
|
|
147
|
+
failOpen: i.definition.failOpen === true,
|
|
148
|
+
failOpenFrom: "default",
|
|
149
|
+
};
|
|
150
|
+
out.ran.push(r);
|
|
151
|
+
return r;
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
|
|
122
155
|
return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
|
|
123
156
|
}
|
|
@@ -150,10 +183,12 @@ export class InterceptorInvoker {
|
|
|
150
183
|
);
|
|
151
184
|
let payload = out.payload;
|
|
152
185
|
for (const i of selected) {
|
|
186
|
+
const ran = this.run(i, phase, out);
|
|
153
187
|
let r: MutationResult;
|
|
154
188
|
try {
|
|
155
189
|
r = (await this.invoke(i, phase, opts, payload)) as MutationResult;
|
|
156
190
|
} catch (err) {
|
|
191
|
+
ran.failed = true;
|
|
157
192
|
if (!this.failed(i, err, out)) return false;
|
|
158
193
|
continue;
|
|
159
194
|
}
|
|
@@ -167,11 +202,13 @@ export class InterceptorInvoker {
|
|
|
167
202
|
/** Parallel validations. Only severity "error" blocks. */
|
|
168
203
|
private async validate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
|
|
169
204
|
const selected = this.select("validation", opts.event, phase);
|
|
205
|
+
const runs = selected.map((i) => this.run(i, phase, out));
|
|
170
206
|
const settled = await Promise.allSettled(selected.map((i) => this.invoke(i, phase, opts, out.payload)));
|
|
171
207
|
let ok = true;
|
|
172
208
|
settled.forEach((s, idx) => {
|
|
173
209
|
const i = selected[idx]!;
|
|
174
210
|
if (s.status === "rejected") {
|
|
211
|
+
runs[idx]!.failed = true;
|
|
175
212
|
if (!this.failed(i, s.reason, out)) ok = false;
|
|
176
213
|
return;
|
|
177
214
|
}
|