@adtl/gateway 0.3.2 → 0.5.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 +86 -2
- package/dist/audit.d.ts +23 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +137 -2
- package/dist/audit.js.map +1 -1
- package/dist/auditCli.d.ts +7 -0
- package/dist/auditCli.d.ts.map +1 -0
- package/dist/auditCli.js +54 -0
- package/dist/auditCli.js.map +1 -0
- package/dist/authorizer.d.ts +50 -0
- package/dist/authorizer.d.ts.map +1 -0
- package/dist/authorizer.js +15 -0
- package/dist/authorizer.js.map +1 -0
- package/dist/cli.js +19 -1
- package/dist/cli.js.map +1 -1
- package/dist/gateway.d.ts +12 -3
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +89 -15
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +8 -1
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +51 -4
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +9 -3
- 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/invoker.d.ts +9 -6
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +11 -9
- package/dist/invoker.js.map +1 -1
- package/dist/ledgerCli.d.ts +7 -0
- package/dist/ledgerCli.d.ts.map +1 -0
- package/dist/ledgerCli.js +106 -0
- package/dist/ledgerCli.js.map +1 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +4 -2
- package/dist/startup.js.map +1 -1
- package/package.json +5 -4
- package/src/audit.ts +144 -2
- package/src/auditCli.ts +53 -0
- package/src/authorizer.ts +60 -0
- package/src/cli.ts +19 -1
- package/src/gateway.ts +96 -17
- package/src/hooks/claudeCode.ts +55 -5
- package/src/hooks/run.ts +9 -3
- package/src/index.ts +3 -0
- package/src/invoker.ts +24 -17
- package/src/ledgerCli.ts +99 -0
- package/src/startup.ts +4 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization: may this caller use this tool, resource or prompt at all?
|
|
3
|
+
*
|
|
4
|
+
* Labels and the policy answer "may this call run, given what the session has
|
|
5
|
+
* seen?". An Authorizer answers the question before it, from who is calling.
|
|
6
|
+
* The gateway asks it on every tools/call, resources/read and prompts/get,
|
|
7
|
+
* before the request interceptors run, and the stricter answer wins: a denial
|
|
8
|
+
* stops the call whatever the policy would say, and an allow still leaves the
|
|
9
|
+
* call to the policy.
|
|
10
|
+
*
|
|
11
|
+
* The caller is the SEP-2624 `context.principal` the gateway passes to the
|
|
12
|
+
* interceptors. Over stdio that is whoever started the gateway (anonymous
|
|
13
|
+
* unless configured); authenticated transports can fill it in per request.
|
|
14
|
+
*
|
|
15
|
+
* An authorizer that throws denies the call (fails closed). In audit mode
|
|
16
|
+
* denials are logged and not enforced, like policy decisions.
|
|
17
|
+
*/
|
|
18
|
+
import type { InvocationContext } from "@adtl/interceptors";
|
|
19
|
+
|
|
20
|
+
export type Principal = NonNullable<InvocationContext["principal"]>;
|
|
21
|
+
|
|
22
|
+
export interface AuthorizationRequest {
|
|
23
|
+
principal: Principal;
|
|
24
|
+
event: "tools/call" | "resources/read" | "prompts/get";
|
|
25
|
+
/** Upstream server id, as named in the config. */
|
|
26
|
+
server: string;
|
|
27
|
+
/** Exactly one of tool, resource or prompt is set, matching `event`. */
|
|
28
|
+
tool?: string;
|
|
29
|
+
resource?: string;
|
|
30
|
+
prompt?: string;
|
|
31
|
+
/** Tool or prompt arguments, for authorizers that decide on them. */
|
|
32
|
+
arguments?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AuthorizationDecision = { allow: true } | { allow: false; reason: string };
|
|
36
|
+
|
|
37
|
+
export interface Authorizer {
|
|
38
|
+
/** Shown in audit entries and error messages, e.g. "rbac". */
|
|
39
|
+
readonly name: string;
|
|
40
|
+
check(request: AuthorizationRequest): AuthorizationDecision | Promise<AuthorizationDecision>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A denial: which authorizer said no, and why. */
|
|
44
|
+
export interface Denial {
|
|
45
|
+
by: string;
|
|
46
|
+
reason: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Ask `authorizer`, failing closed. Returns the denial, or undefined if allowed. */
|
|
50
|
+
export async function authorize(authorizer: Authorizer, request: AuthorizationRequest): Promise<Denial | undefined> {
|
|
51
|
+
let d: AuthorizationDecision;
|
|
52
|
+
try {
|
|
53
|
+
d = await authorizer.check(request);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { by: authorizer.name, reason: `${authorizer.name} failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
56
|
+
}
|
|
57
|
+
if (d?.allow === true) return undefined;
|
|
58
|
+
const reason = d && "reason" in d && typeof d.reason === "string" && d.reason ? d.reason : `${authorizer.name} denied the call`;
|
|
59
|
+
return { by: authorizer.name, reason };
|
|
60
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* adtl-gateway --config adtl.yaml [--audit]
|
|
7
7
|
* adtl-gateway init --config adtl.yaml [--from .mcp.json] [--write]
|
|
8
8
|
* adtl-gateway hook --config adtl.yaml [--gateway-server adtl]
|
|
9
|
+
* adtl-gateway ledger stats|verify|declassify|declassified
|
|
10
|
+
* adtl-gateway audit verify [<file>]
|
|
9
11
|
*
|
|
10
12
|
* Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
|
|
11
13
|
* Logs go to stderr; stdout carries MCP JSON-RPC only.
|
|
@@ -13,13 +15,17 @@
|
|
|
13
15
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
16
|
import { EnvExpansionError } from "./config.js";
|
|
15
17
|
import { GATEWAY_VERSION } from "./gateway.js";
|
|
18
|
+
import { runAuditCommand } from "./auditCli.js";
|
|
16
19
|
import { runHook } from "./hooks/run.js";
|
|
20
|
+
import { runLedgerCommand } from "./ledgerCli.js";
|
|
17
21
|
import { runInit, type InitResult } from "./init/run.js";
|
|
18
22
|
import { GatewayConfigError, loadGatewayConfig, startGateway } from "./startup.js";
|
|
19
23
|
|
|
20
24
|
const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
|
|
21
25
|
adtl-gateway init --config <adtl.yaml> [--from <.mcp.json>] [--write]
|
|
22
26
|
adtl-gateway hook --config <adtl.yaml> [--gateway-server <name>]
|
|
27
|
+
adtl-gateway ledger stats|verify|declassify|declassified (see: adtl-gateway ledger --help)
|
|
28
|
+
adtl-gateway audit verify [<file>] (see: adtl-gateway audit --help)
|
|
23
29
|
|
|
24
30
|
Runs an MCP server over stdio that forwards tool calls to the upstream
|
|
25
31
|
servers in the config, checking and labeling each call with ADTL.
|
|
@@ -86,6 +92,18 @@ function parseArgs(argv: string[]): Args {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
async function main(): Promise<void> {
|
|
95
|
+
if (process.argv[2] === "ledger") {
|
|
96
|
+
const r = await runLedgerCommand(process.argv.slice(3));
|
|
97
|
+
console.log(r.stdout);
|
|
98
|
+
process.exitCode = r.exitCode;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (process.argv[2] === "audit") {
|
|
102
|
+
const r = await runAuditCommand(process.argv.slice(3));
|
|
103
|
+
console.log(r.stdout);
|
|
104
|
+
process.exitCode = r.exitCode;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
89
107
|
const args = parseArgs(process.argv.slice(2));
|
|
90
108
|
// --help and --version print to stdout and exit; MCP is never served in these modes.
|
|
91
109
|
if (args.help) return void console.log(USAGE);
|
|
@@ -172,7 +190,7 @@ async function init(path: string, write: boolean, from?: string): Promise<void>
|
|
|
172
190
|
}
|
|
173
191
|
|
|
174
192
|
main().catch((err: unknown) => {
|
|
175
|
-
const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError;
|
|
193
|
+
const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit"].includes(process.argv[2] ?? "");
|
|
176
194
|
console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
|
|
177
195
|
// For Claude Code hooks only exit code 2 stops the call; any other code lets it run.
|
|
178
196
|
process.exit(process.argv[2] === "hook" ? 2 : 1);
|
package/src/gateway.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The gateway: one MCP server facing the agent, many MCP clients facing the
|
|
3
|
-
* upstream servers, and
|
|
3
|
+
* upstream servers, and the interceptors in between.
|
|
4
4
|
*
|
|
5
5
|
* tools/list → every upstream's tools, renamed <upstream>__<tool>
|
|
6
6
|
* tools/call → request interceptors (adtl.policy) → upstream → response
|
|
@@ -15,8 +15,14 @@
|
|
|
15
15
|
* `isError: true` explaining why, so the model can tell the user.
|
|
16
16
|
*
|
|
17
17
|
* An "ask" decision holds the call and asks a human through the approval
|
|
18
|
-
* broker (e.g. a phone notification).
|
|
19
|
-
*
|
|
18
|
+
* broker (e.g. a phone notification). Once approved, the gateway checks the
|
|
19
|
+
* call again with the confirmation in `context.confirmations` (SEP-2624
|
|
20
|
+
* draft) and runs it only if every validator then allows it. Denied,
|
|
21
|
+
* unanswered or undeliverable calls are stopped. Without an approval channel,
|
|
22
|
+
* "ask" blocks.
|
|
23
|
+
*
|
|
24
|
+
* An optional Authorizer decides first whether the caller may use the tool,
|
|
25
|
+
* resource or prompt at all; a denial stops the call before the policy runs.
|
|
20
26
|
*
|
|
21
27
|
* In audit mode (the invoker runs every interceptor with `mode: "audit"`)
|
|
22
28
|
* nothing is stopped or changed: calls the policy would stop are forwarded,
|
|
@@ -46,12 +52,14 @@ import {
|
|
|
46
52
|
type Tool,
|
|
47
53
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
48
54
|
import type { InvocationContext } from "@adtl/interceptors";
|
|
55
|
+
import { callBindingHash } from "@adtl/labels";
|
|
49
56
|
import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
|
|
50
57
|
import type { AuditEntry, AuditSink } from "./audit.js";
|
|
58
|
+
import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
|
|
51
59
|
import { TOOL_SEPARATOR } from "./config.js";
|
|
52
|
-
import type { Blocked,
|
|
60
|
+
import type { Blocked, PhaseOutcome, InterceptorInvoker } from "./invoker.js";
|
|
53
61
|
|
|
54
|
-
export const GATEWAY_VERSION = "0.
|
|
62
|
+
export const GATEWAY_VERSION = "0.5.0";
|
|
55
63
|
|
|
56
64
|
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
57
65
|
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
@@ -96,6 +104,10 @@ export interface AdtlGatewayOptions {
|
|
|
96
104
|
logger?: GatewayLogger;
|
|
97
105
|
/** Asks humans about "ask" decisions and sends notices. */
|
|
98
106
|
approvals?: ApprovalBroker;
|
|
107
|
+
/** Decides whether the caller may use a tool, resource or prompt at all, before the policy. */
|
|
108
|
+
authorizer?: Authorizer;
|
|
109
|
+
/** The caller, passed to the authorizer and the interceptors. Default: anonymous. */
|
|
110
|
+
principal?: Principal;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
export interface CallOptions {
|
|
@@ -132,7 +144,7 @@ function approvalMessage(b: Blocked, a: ApprovalResult): string {
|
|
|
132
144
|
}
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
function infoOf(outcome:
|
|
147
|
+
function infoOf(outcome: PhaseOutcome, interceptor: string): Record<string, unknown> {
|
|
136
148
|
return outcome.results.find((r) => r.interceptor === interceptor)?.info ?? {};
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -143,6 +155,8 @@ export class AdtlGateway {
|
|
|
143
155
|
private readonly audit: AuditSink | undefined;
|
|
144
156
|
private readonly log: GatewayLogger;
|
|
145
157
|
private readonly approvals: ApprovalBroker | undefined;
|
|
158
|
+
private readonly authorizer: Authorizer | undefined;
|
|
159
|
+
private readonly principal: Principal;
|
|
146
160
|
private routes = new Map<string, Route>();
|
|
147
161
|
private promptRoutes = new Map<string, PromptRoute>();
|
|
148
162
|
/** Resource URI → the upstreams that list it. */
|
|
@@ -156,6 +170,13 @@ export class AdtlGateway {
|
|
|
156
170
|
this.audit = opts.audit;
|
|
157
171
|
this.log = opts.logger ?? stderrLogger;
|
|
158
172
|
this.approvals = opts.approvals;
|
|
173
|
+
this.authorizer = opts.authorizer;
|
|
174
|
+
this.principal = opts.principal ?? { type: "anonymous" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Ask the authorizer, if any. Returns the denial; in audit mode the caller logs it and carries on. */
|
|
178
|
+
private async authorize(request: Omit<AuthorizationRequest, "principal">): Promise<Denial | undefined> {
|
|
179
|
+
return this.authorizer ? authorize(this.authorizer, { principal: this.principal, ...request }) : undefined;
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
/** The downstream MCP server the agent connects to. */
|
|
@@ -331,8 +352,8 @@ export class AdtlGateway {
|
|
|
331
352
|
}
|
|
332
353
|
|
|
333
354
|
/**
|
|
334
|
-
* Forward a resources/read or prompts/get request through the
|
|
335
|
-
*
|
|
355
|
+
* Forward a resources/read or prompts/get request through the interceptors:
|
|
356
|
+
* request interceptors → upstream → response interceptors (adtl.label).
|
|
336
357
|
* These requests have no tool result to carry an error, so a stopped request
|
|
337
358
|
* becomes a JSON-RPC error.
|
|
338
359
|
*/
|
|
@@ -347,10 +368,12 @@ export class AdtlGateway {
|
|
|
347
368
|
const context: InvocationContext = {
|
|
348
369
|
sessionId: this.sessionId,
|
|
349
370
|
timestamp: new Date().toISOString(),
|
|
350
|
-
principal:
|
|
371
|
+
principal: this.principal,
|
|
372
|
+
target: upstream,
|
|
351
373
|
};
|
|
352
374
|
const config = { server: upstream };
|
|
353
375
|
const audit = this.invoker.modeOverride === "audit" ? { mode: "audit" as const } : {};
|
|
376
|
+
let denied: Denial | undefined;
|
|
354
377
|
const finish = (entry: Pick<AuditEntry, "outcome"> & Partial<AuditEntry>) =>
|
|
355
378
|
this.audit?.write({
|
|
356
379
|
ts: context.timestamp!,
|
|
@@ -358,11 +381,25 @@ export class AdtlGateway {
|
|
|
358
381
|
event,
|
|
359
382
|
server: upstream,
|
|
360
383
|
...target,
|
|
384
|
+
...(denied ? { denied } : {}),
|
|
361
385
|
...entry,
|
|
362
386
|
durationMs: Math.round(performance.now() - started),
|
|
363
387
|
});
|
|
364
388
|
const what = "resource" in target ? `resource ${target.resource}` : `prompt ${upstream}${TOOL_SEPARATOR}${target.prompt}`;
|
|
365
389
|
|
|
390
|
+
denied = await this.authorize({
|
|
391
|
+
event,
|
|
392
|
+
server: upstream,
|
|
393
|
+
...target,
|
|
394
|
+
...(params.arguments ? { arguments: params.arguments as Record<string, unknown> } : {}),
|
|
395
|
+
});
|
|
396
|
+
if (denied && !("mode" in audit)) {
|
|
397
|
+
finish({ outcome: "blocked", decision: "block", reason: denied.reason });
|
|
398
|
+
this.log.info(`denied ${what}: ${denied.reason}`);
|
|
399
|
+
throw new McpError(ErrorCode.InvalidRequest, `ADTL blocked this request: ${denied.reason}`);
|
|
400
|
+
}
|
|
401
|
+
if (denied) this.log.info(`audit: would deny ${what}: ${denied.reason}`);
|
|
402
|
+
|
|
366
403
|
const req = await this.invoker.runRequest({ event, payload: { method: event, params }, config, context });
|
|
367
404
|
if (req.blocked) {
|
|
368
405
|
finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, warnings: req.warnings });
|
|
@@ -388,7 +425,8 @@ export class AdtlGateway {
|
|
|
388
425
|
throw new McpError(ErrorCode.InternalError, `ADTL could not label this content, so it was withheld: ${res.blocked.reason}`);
|
|
389
426
|
}
|
|
390
427
|
const label = infoOf(res, "adtl.label");
|
|
391
|
-
finish({ outcome: "forwarded", ...audit, label: label.label,
|
|
428
|
+
finish({ outcome: "forwarded", ...audit, label: label.label, ...(label.ledger ? { ledger: label.ledger } : {}),
|
|
429
|
+
...(label.records ? { records: label.records } : {}), sessionLabel: label.sessionLabel, warnings });
|
|
392
430
|
return (res.payload as { result: R }).result;
|
|
393
431
|
}
|
|
394
432
|
|
|
@@ -405,33 +443,52 @@ export class AdtlGateway {
|
|
|
405
443
|
const context: InvocationContext = {
|
|
406
444
|
sessionId: this.sessionId,
|
|
407
445
|
timestamp: new Date().toISOString(),
|
|
408
|
-
principal:
|
|
446
|
+
principal: this.principal,
|
|
447
|
+
target: route.upstream,
|
|
409
448
|
};
|
|
410
449
|
const config: Record<string, unknown> = {
|
|
411
450
|
server: route.upstream,
|
|
412
451
|
...(route.annotations ? { toolAnnotations: route.annotations } : {}),
|
|
413
452
|
};
|
|
414
453
|
const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
454
|
+
let denied: Denial | undefined;
|
|
415
455
|
const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
|
|
416
|
-
this.audit?.write({ ts: context.timestamp!, ...base, ...entry, durationMs: Math.round(performance.now() - started) });
|
|
456
|
+
this.audit?.write({ ts: context.timestamp!, ...base, ...(denied ? { denied } : {}), ...entry, durationMs: Math.round(performance.now() - started) });
|
|
457
|
+
|
|
458
|
+
// 0. Authorization: may this caller use this tool at all? A denial wins over any policy decision.
|
|
459
|
+
denied = await this.authorize({ event: "tools/call", server: route.upstream, tool: route.tool, arguments: args });
|
|
460
|
+
if (denied && this.invoker.modeOverride !== "audit") {
|
|
461
|
+
finish({ outcome: "blocked", decision: "block", reason: denied.reason });
|
|
462
|
+
this.log.info(`denied ${name}: ${denied.reason}`);
|
|
463
|
+
this.approvals?.blocked({ sessionId: this.sessionId, server: route.upstream, tool: route.tool, reason: denied.reason, matched: [`authorizer:${denied.by}`] });
|
|
464
|
+
return textError(`ADTL blocked this call: ${denied.reason}. You are not allowed to use this tool, so the call was not run.`, {
|
|
465
|
+
decision: "block",
|
|
466
|
+
reason: denied.reason,
|
|
467
|
+
authorizer: denied.by,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (denied) this.log.info(`audit: would deny ${name}: ${denied.reason}`);
|
|
417
471
|
|
|
418
472
|
// 1. Request phase: adtl.policy decides whether the call may run.
|
|
419
473
|
const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
|
|
420
|
-
|
|
474
|
+
let req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
|
|
421
475
|
const policy = infoOf(req, "adtl.policy");
|
|
422
476
|
let approval: ApprovalResult | undefined;
|
|
423
477
|
if (req.blocked) {
|
|
424
478
|
const b = req.blocked;
|
|
479
|
+
// What the agent sees; the confirmation token stays inside the gateway.
|
|
480
|
+
const shown = { interceptor: b.interceptor, decision: b.decision, reason: b.reason };
|
|
425
481
|
const stopped = { decision: b.decision, reason: b.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel };
|
|
426
482
|
const matched = Array.isArray(policy.matched) ? (policy.matched as string[]) : [];
|
|
427
|
-
|
|
483
|
+
const hash = b.confirmation?.callHash ?? policy.callHash;
|
|
484
|
+
if (b.decision === "ask" && this.approvals?.canApprove && typeof hash === "string") {
|
|
428
485
|
this.log.info(`ask ${name}: waiting for approval (${b.reason})`);
|
|
429
486
|
approval = await this.waitForApproval(opts, () =>
|
|
430
487
|
this.approvals!.request({
|
|
431
488
|
sessionId: this.sessionId,
|
|
432
489
|
server: route.upstream,
|
|
433
490
|
tool: route.tool,
|
|
434
|
-
callHash:
|
|
491
|
+
callHash: hash,
|
|
435
492
|
reason: b.reason,
|
|
436
493
|
matched,
|
|
437
494
|
...(policy.sessionLabel !== undefined ? { sessionLabel: policy.sessionLabel } : {}),
|
|
@@ -440,13 +497,33 @@ export class AdtlGateway {
|
|
|
440
497
|
this.log.info(`ask ${name}: ${approval.outcome}`);
|
|
441
498
|
if (approval.outcome !== "approved") {
|
|
442
499
|
finish({ outcome: "blocked", ...stopped, approval, warnings: req.warnings });
|
|
443
|
-
return textError(approvalMessage(b, approval), { ...
|
|
500
|
+
return textError(approvalMessage(b, approval), { ...shown, callHash: policy.callHash, approval });
|
|
501
|
+
}
|
|
502
|
+
if (b.confirmation) {
|
|
503
|
+
// Check the call again with the confirmation; it runs only if every validator now allows it.
|
|
504
|
+
const c = b.confirmation;
|
|
505
|
+
const confirmations = [{
|
|
506
|
+
interceptor: b.interceptor,
|
|
507
|
+
id: c.id,
|
|
508
|
+
callHash: c.callHash,
|
|
509
|
+
...(c.token !== undefined ? { token: c.token } : {}),
|
|
510
|
+
decidedAt: new Date().toISOString(),
|
|
511
|
+
...(approval.approver ? { principal: { type: "user" as const, id: approval.approver } } : {}),
|
|
512
|
+
}];
|
|
513
|
+
req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context: { ...context, confirmations } });
|
|
514
|
+
const sent = callBindingHash({ event: "tools/call", phase: "request", target: route.upstream, payload: req.payload });
|
|
515
|
+
const recheck = req.blocked?.reason ?? (sent !== c.callHash ? "the call changed after it was approved" : undefined);
|
|
516
|
+
if (recheck !== undefined) {
|
|
517
|
+
finish({ outcome: "blocked", ...stopped, approval, reason: recheck, warnings: req.warnings });
|
|
518
|
+
this.log.info(`ask ${name}: approved but blocked on recheck (${recheck})`);
|
|
519
|
+
return textError(`ADTL blocked this call after it was approved: ${recheck}. The call was not run.`, { ...shown, callHash: policy.callHash, approval, reason: recheck });
|
|
520
|
+
}
|
|
444
521
|
}
|
|
445
522
|
} else {
|
|
446
523
|
finish({ outcome: "blocked", ...stopped, warnings: req.warnings });
|
|
447
524
|
this.log.info(`${b.decision} ${name}: ${b.reason}`);
|
|
448
525
|
this.approvals?.blocked({ sessionId: this.sessionId, server: route.upstream, tool: route.tool, reason: b.reason, matched });
|
|
449
|
-
return textError(blockedMessage(b), { ...
|
|
526
|
+
return textError(blockedMessage(b), { ...shown, callHash: policy.callHash });
|
|
450
527
|
}
|
|
451
528
|
}
|
|
452
529
|
const forwarded = (req.payload as typeof requestPayload).params;
|
|
@@ -493,6 +570,8 @@ export class AdtlGateway {
|
|
|
493
570
|
...(audit ? { mode: "audit" as const } : {}),
|
|
494
571
|
callHash: policy.callHash,
|
|
495
572
|
label: label.label,
|
|
573
|
+
...(label.ledger ? { ledger: label.ledger } : {}),
|
|
574
|
+
...(label.records ? { records: label.records } : {}),
|
|
496
575
|
sessionLabel: label.sessionLabel,
|
|
497
576
|
warnings: [...req.warnings, ...res.warnings],
|
|
498
577
|
});
|
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
* by Claude Code's session id, which the gateway reads from
|
|
18
18
|
* `CLAUDE_CODE_SESSION_ID`.
|
|
19
19
|
*
|
|
20
|
+
* With a ledger, PostToolUse also re-labels output that contains content a
|
|
21
|
+
* tainted session wrote earlier, and, when this session is tainted, records
|
|
22
|
+
* what the tool was given (file contents, edits, prompts) and the lines a
|
|
23
|
+
* shell command added to files.
|
|
24
|
+
*
|
|
20
25
|
* Errors fail closed: a PreToolUse hook that can't decide denies the call.
|
|
21
26
|
*/
|
|
22
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
27
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
23
28
|
import {
|
|
24
29
|
callHash,
|
|
25
30
|
evaluatePolicy,
|
|
@@ -29,7 +34,9 @@ import {
|
|
|
29
34
|
type Decision,
|
|
30
35
|
type PolicyDecision,
|
|
31
36
|
type SessionStore,
|
|
37
|
+
join,
|
|
32
38
|
} from "@adtl/labels";
|
|
39
|
+
import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
|
|
33
40
|
import type { AuditEntry, AuditSink } from "../audit.js";
|
|
34
41
|
import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
|
|
35
42
|
|
|
@@ -52,8 +59,10 @@ export interface HookOptions {
|
|
|
52
59
|
/** `audit`: decide and log, but never deny or ask. */
|
|
53
60
|
mode?: "active" | "audit";
|
|
54
61
|
audit?: AuditSink;
|
|
55
|
-
/**
|
|
56
|
-
|
|
62
|
+
/** Label ledger shared with the gateway. */
|
|
63
|
+
ledger?: Ledger;
|
|
64
|
+
/** 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 };
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/** What the hook process prints and exits with. */
|
|
@@ -115,6 +124,14 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
115
124
|
if (protect.sessionsDir && inside(abs, resolve(protect.sessionsDir))) {
|
|
116
125
|
return { decision: "block", reason: "The agent may not change ADTL session labels" };
|
|
117
126
|
}
|
|
127
|
+
// Prefix match also covers SQLite's -wal and -shm files.
|
|
128
|
+
if (protect.ledgerPath && abs.startsWith(resolve(protect.ledgerPath))) {
|
|
129
|
+
return { decision: "block", reason: "The agent may not change the ADTL ledger" };
|
|
130
|
+
}
|
|
131
|
+
// Prefix match also covers the .lock file.
|
|
132
|
+
if (protect.auditPath && abs.startsWith(resolve(protect.auditPath))) {
|
|
133
|
+
return { decision: "block", reason: "The agent may not change the ADTL audit log" };
|
|
134
|
+
}
|
|
118
135
|
}
|
|
119
136
|
const command = typeof args.command === "string" ? args.command : undefined;
|
|
120
137
|
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
@@ -122,10 +139,30 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
122
139
|
if (marks.some((m) => command.includes(m))) {
|
|
123
140
|
return { decision: "ask", reason: "This command touches ADTL session labels" };
|
|
124
141
|
}
|
|
142
|
+
const ledgerMarks = ["ledger.db", "ledger declassify", protect.ledgerPath].filter((m): m is string => !!m);
|
|
143
|
+
if (ledgerMarks.some((m) => command.includes(m))) {
|
|
144
|
+
return { decision: "ask", reason: "This command touches the ADTL ledger" };
|
|
145
|
+
}
|
|
146
|
+
if (protect.auditPath && command.includes(basename(protect.auditPath))) {
|
|
147
|
+
return { decision: "ask", reason: "This command touches the ADTL audit log" };
|
|
148
|
+
}
|
|
125
149
|
}
|
|
126
150
|
return undefined;
|
|
127
151
|
}
|
|
128
152
|
|
|
153
|
+
/** Text a shell command added to files, from Claude Code's `bashEditDiff` (one string per file). */
|
|
154
|
+
export function addedLines(output: unknown): string[] {
|
|
155
|
+
const files = isRecord(output) && isRecord(output.bashEditDiff) && Array.isArray(output.bashEditDiff.files) ? output.bashEditDiff.files : [];
|
|
156
|
+
const out: string[] = [];
|
|
157
|
+
for (const f of files) {
|
|
158
|
+
const hunks = isRecord(f) && Array.isArray(f.hunks) ? f.hunks : [];
|
|
159
|
+
const lines = hunks.flatMap((h) => (isRecord(h) && Array.isArray(h.lines) ? h.lines : []));
|
|
160
|
+
const added = lines.filter((l): l is string => typeof l === "string" && l.startsWith("+")).map((l) => l.slice(1));
|
|
161
|
+
if (added.length) out.push(added.join("\n"));
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
129
166
|
export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
130
167
|
const event = input.hook_event_name;
|
|
131
168
|
if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
|
|
@@ -139,9 +176,22 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
139
176
|
const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const };
|
|
140
177
|
|
|
141
178
|
if (event !== "PreToolUse") {
|
|
142
|
-
const
|
|
179
|
+
const output = input.tool_response ?? input.error ?? null;
|
|
180
|
+
let label = labelResult({ ...route, result: output }, config);
|
|
181
|
+
let ledger: { chunks: number; writers: string[] } | undefined;
|
|
182
|
+
if (opts.ledger) {
|
|
183
|
+
const match = opts.ledger.lookup(valueHashes(output));
|
|
184
|
+
if (match) {
|
|
185
|
+
label = join(label, match.label);
|
|
186
|
+
ledger = { chunks: match.chunks, writers: match.writers };
|
|
187
|
+
}
|
|
188
|
+
// What the tool was given came from the session as it was before this output.
|
|
189
|
+
const before = opts.sessions.get(input.session_id);
|
|
190
|
+
const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
|
|
191
|
+
opts.ledger.record(written, before, input.session_id, route);
|
|
192
|
+
}
|
|
143
193
|
const sessionLabel = opts.sessions.observe(input.session_id, label);
|
|
144
|
-
opts.audit?.write({ ...base, outcome: "forwarded", label, sessionLabel, durationMs: 0 });
|
|
194
|
+
opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
|
|
145
195
|
return NOTHING;
|
|
146
196
|
}
|
|
147
197
|
|
package/src/hooks/run.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { FileSessionStore, defaultSessionsDir } from "@adtl/labels";
|
|
12
|
+
import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
12
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
13
14
|
import { parse as parseYaml } from "yaml";
|
|
14
15
|
import { JsonlAuditLog } from "../audit.js";
|
|
@@ -34,14 +35,19 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
34
35
|
const text = await readFile(path, "utf8");
|
|
35
36
|
const config = parseConfigText(text, path);
|
|
36
37
|
const gw = ((parseYaml(text) ?? {}) as { gateway?: { audit?: unknown; mode?: unknown } }).gateway ?? {};
|
|
37
|
-
const
|
|
38
|
+
const env = opts.env ?? process.env;
|
|
39
|
+
const sessionsDir = defaultSessionsDir(env);
|
|
40
|
+
const ledgerPath = config.ledger.path ? resolve(dirname(path), config.ledger.path) : defaultLedgerPath(env);
|
|
41
|
+
const ledger = config.ledger.enabled ? new Ledger(ledgerPath, { retentionDays: config.ledger.retentionDays }) : undefined;
|
|
42
|
+
const auditPath = typeof gw.audit === "string" ? resolve(dirname(path), gw.audit) : undefined;
|
|
38
43
|
return handleHook(input, {
|
|
39
44
|
config,
|
|
40
45
|
sessions: new FileSessionStore(sessionsDir),
|
|
46
|
+
...(ledger ? { ledger } : {}),
|
|
41
47
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
42
48
|
mode: gw.mode === "audit" ? "audit" : "active",
|
|
43
|
-
...(
|
|
44
|
-
protect: { configPath: path, sessionsDir },
|
|
49
|
+
...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
|
|
50
|
+
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}) },
|
|
45
51
|
});
|
|
46
52
|
} catch (err) {
|
|
47
53
|
return failure(input.hook_event_name, (err as Error).message);
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./config.js";
|
|
2
2
|
export * from "./invoker.js";
|
|
3
3
|
export * from "./audit.js";
|
|
4
|
+
export * from "./authorizer.js";
|
|
4
5
|
export * from "./approvals.js";
|
|
5
6
|
export * from "./ntfy.js";
|
|
6
7
|
export * from "./email.js";
|
|
@@ -8,3 +9,5 @@ export * from "./gateway.js";
|
|
|
8
9
|
export * from "./startup.js";
|
|
9
10
|
export * from "./init/index.js";
|
|
10
11
|
export * from "./hooks/index.js";
|
|
12
|
+
export * from "./ledgerCli.js";
|
|
13
|
+
export * from "./auditCli.js";
|
package/src/invoker.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - Receiving (response phase, upstream → gateway): Validate (parallel) →
|
|
7
7
|
* Mutate (sequential).
|
|
8
8
|
*
|
|
9
|
-
* Only `severity: "error"` blocks.
|
|
9
|
+
* Only `severity: "error"` blocks. A blocking result with a `confirmation`
|
|
10
|
+
* request is an "ask"; the gateway may ask a human and re-validate. Audit-mode interceptors never block, and
|
|
10
11
|
* their mutations are not applied. An interceptor that fails (error or
|
|
11
12
|
* timeout) blocks the message unless it declares `failOpen: true`.
|
|
12
13
|
*
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
DECISION_INFO_KEY,
|
|
19
20
|
INVOKE_METHOD,
|
|
20
21
|
LIST_METHOD,
|
|
22
|
+
type ConfirmationRequest,
|
|
21
23
|
type InterceptorDefinition,
|
|
22
24
|
type InterceptorResult,
|
|
23
25
|
type InvocationContext,
|
|
@@ -49,9 +51,11 @@ export interface Blocked {
|
|
|
49
51
|
interceptor: string;
|
|
50
52
|
decision: Decision;
|
|
51
53
|
reason: string;
|
|
54
|
+
/** Set when the validator would accept a human confirmation. */
|
|
55
|
+
confirmation?: ConfirmationRequest;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
export interface
|
|
58
|
+
export interface PhaseOutcome {
|
|
55
59
|
payload: unknown;
|
|
56
60
|
blocked?: Blocked;
|
|
57
61
|
results: InterceptorResult[];
|
|
@@ -96,15 +100,15 @@ export class InterceptorInvoker {
|
|
|
96
100
|
return this.interceptors.map((i) => i.definition);
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
async runRequest(opts: InvokeOptions): Promise<
|
|
100
|
-
const out:
|
|
103
|
+
async runRequest(opts: InvokeOptions): Promise<PhaseOutcome> {
|
|
104
|
+
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
101
105
|
if (!(await this.mutate("request", opts, out))) return out;
|
|
102
106
|
await this.validate("request", opts, out);
|
|
103
107
|
return out;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
async runResponse(opts: InvokeOptions): Promise<
|
|
107
|
-
const out:
|
|
110
|
+
async runResponse(opts: InvokeOptions): Promise<PhaseOutcome> {
|
|
111
|
+
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
108
112
|
if (!(await this.validate("response", opts, out))) return out;
|
|
109
113
|
await this.mutate("response", opts, out);
|
|
110
114
|
return out;
|
|
@@ -128,8 +132,8 @@ export class InterceptorInvoker {
|
|
|
128
132
|
return res as unknown as InterceptorResult;
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
/** Handle an interceptor failure. Returns false if the
|
|
132
|
-
private failed(i: BoundInterceptor, err: unknown, out:
|
|
135
|
+
/** Handle an interceptor failure. Returns false if the phase must stop. */
|
|
136
|
+
private failed(i: BoundInterceptor, err: unknown, out: PhaseOutcome): boolean {
|
|
133
137
|
const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
134
138
|
if (this.isAudit(i) || i.definition.failOpen === true) {
|
|
135
139
|
out.warnings.push(msg);
|
|
@@ -140,12 +144,12 @@ export class InterceptorInvoker {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
/** Sequential mutations, atomic: on failure the original payload is kept. */
|
|
143
|
-
private async mutate(phase: Phase, opts: InvokeOptions, out:
|
|
144
|
-
const
|
|
147
|
+
private async mutate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
|
|
148
|
+
const selected = this.select("mutation", opts.event, phase).sort(
|
|
145
149
|
(a, b) => priority(a.definition, phase) - priority(b.definition, phase) || a.definition.name.localeCompare(b.definition.name),
|
|
146
150
|
);
|
|
147
151
|
let payload = out.payload;
|
|
148
|
-
for (const i of
|
|
152
|
+
for (const i of selected) {
|
|
149
153
|
let r: MutationResult;
|
|
150
154
|
try {
|
|
151
155
|
r = (await this.invoke(i, phase, opts, payload)) as MutationResult;
|
|
@@ -161,12 +165,12 @@ export class InterceptorInvoker {
|
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
/** Parallel validations. Only severity "error" blocks. */
|
|
164
|
-
private async validate(phase: Phase, opts: InvokeOptions, out:
|
|
165
|
-
const
|
|
166
|
-
const settled = await Promise.allSettled(
|
|
168
|
+
private async validate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
|
|
169
|
+
const selected = this.select("validation", opts.event, phase);
|
|
170
|
+
const settled = await Promise.allSettled(selected.map((i) => this.invoke(i, phase, opts, out.payload)));
|
|
167
171
|
let ok = true;
|
|
168
172
|
settled.forEach((s, idx) => {
|
|
169
|
-
const i =
|
|
173
|
+
const i = selected[idx]!;
|
|
170
174
|
if (s.status === "rejected") {
|
|
171
175
|
if (!this.failed(i, s.reason, out)) ok = false;
|
|
172
176
|
return;
|
|
@@ -176,9 +180,12 @@ export class InterceptorInvoker {
|
|
|
176
180
|
if (r.valid || r.severity !== "error" || this.isAudit(i)) return;
|
|
177
181
|
// Keep the strictest rejection, so an "ask" never hides another validator's "block".
|
|
178
182
|
if (!out.blocked || out.blocked.decision === "ask") {
|
|
179
|
-
|
|
183
|
+
// Older ADTL interceptor servers carry "ask" only in info.
|
|
184
|
+
const decision = r.confirmation ? "ask" : ((r.info?.[DECISION_INFO_KEY] as Decision | undefined) ?? "block");
|
|
180
185
|
const reason = r.messages?.map((m) => m.message).join("; ") || `${i.definition.name} rejected the call`;
|
|
181
|
-
if (!out.blocked || decision === "block")
|
|
186
|
+
if (!out.blocked || decision === "block") {
|
|
187
|
+
out.blocked = { interceptor: i.definition.name, decision, reason, ...(r.confirmation ? { confirmation: r.confirmation } : {}) };
|
|
188
|
+
}
|
|
182
189
|
}
|
|
183
190
|
ok = false;
|
|
184
191
|
});
|