@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.
Files changed (53) hide show
  1. package/README.md +86 -2
  2. package/dist/audit.d.ts +23 -0
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +137 -2
  5. package/dist/audit.js.map +1 -1
  6. package/dist/auditCli.d.ts +7 -0
  7. package/dist/auditCli.d.ts.map +1 -0
  8. package/dist/auditCli.js +54 -0
  9. package/dist/auditCli.js.map +1 -0
  10. package/dist/authorizer.d.ts +50 -0
  11. package/dist/authorizer.d.ts.map +1 -0
  12. package/dist/authorizer.js +15 -0
  13. package/dist/authorizer.js.map +1 -0
  14. package/dist/cli.js +19 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/gateway.d.ts +12 -3
  17. package/dist/gateway.d.ts.map +1 -1
  18. package/dist/gateway.js +89 -15
  19. package/dist/gateway.js.map +1 -1
  20. package/dist/hooks/claudeCode.d.ts +8 -1
  21. package/dist/hooks/claudeCode.d.ts.map +1 -1
  22. package/dist/hooks/claudeCode.js +51 -4
  23. package/dist/hooks/claudeCode.js.map +1 -1
  24. package/dist/hooks/run.d.ts.map +1 -1
  25. package/dist/hooks/run.js +9 -3
  26. package/dist/hooks/run.js.map +1 -1
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/invoker.d.ts +9 -6
  32. package/dist/invoker.d.ts.map +1 -1
  33. package/dist/invoker.js +11 -9
  34. package/dist/invoker.js.map +1 -1
  35. package/dist/ledgerCli.d.ts +7 -0
  36. package/dist/ledgerCli.d.ts.map +1 -0
  37. package/dist/ledgerCli.js +106 -0
  38. package/dist/ledgerCli.js.map +1 -0
  39. package/dist/startup.d.ts.map +1 -1
  40. package/dist/startup.js +4 -2
  41. package/dist/startup.js.map +1 -1
  42. package/package.json +5 -4
  43. package/src/audit.ts +144 -2
  44. package/src/auditCli.ts +53 -0
  45. package/src/authorizer.ts +60 -0
  46. package/src/cli.ts +19 -1
  47. package/src/gateway.ts +96 -17
  48. package/src/hooks/claudeCode.ts +55 -5
  49. package/src/hooks/run.ts +9 -3
  50. package/src/index.ts +3 -0
  51. package/src/invoker.ts +24 -17
  52. package/src/ledgerCli.ts +99 -0
  53. 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 an interceptor chain in between.
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). Approved calls run; denied, unanswered
19
- * or undeliverable ones are stopped. Without an approval channel, "ask" blocks.
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, ChainOutcome, InterceptorInvoker } from "./invoker.js";
60
+ import type { Blocked, PhaseOutcome, InterceptorInvoker } from "./invoker.js";
53
61
 
54
- export const GATEWAY_VERSION = "0.3.2";
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: ChainOutcome, interceptor: string): Record<string, unknown> {
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 interceptor
335
- * chain: request interceptors → upstream → response interceptors (adtl.label).
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: { type: "anonymous" },
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, sessionLabel: label.sessionLabel, warnings });
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: { type: "anonymous" },
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
- const req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
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
- if (b.decision === "ask" && this.approvals?.canApprove && typeof policy.callHash === "string") {
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: policy.callHash as string,
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), { ...b, callHash: policy.callHash, 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), { ...b, callHash: policy.callHash });
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
  });
@@ -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
- /** Files the agent must not change: the ADTL config and the session directory. */
56
- protect?: { configPath?: string; sessionsDir?: string };
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 label = labelResult({ ...route, result: input.tool_response ?? input.error ?? null }, config);
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 sessionsDir = defaultSessionsDir(opts.env ?? process.env);
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
- ...(typeof gw.audit === "string" ? { audit: new JsonlAuditLog(resolve(dirname(path), gw.audit)) } : {}),
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. Audit-mode interceptors never block, and
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 ChainOutcome {
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<ChainOutcome> {
100
- const out: ChainOutcome = { payload: opts.payload, results: [], warnings: [] };
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<ChainOutcome> {
107
- const out: ChainOutcome = { payload: opts.payload, results: [], warnings: [] };
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 chain must stop. */
132
- private failed(i: BoundInterceptor, err: unknown, out: ChainOutcome): boolean {
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: ChainOutcome): Promise<boolean> {
144
- const chain = this.select("mutation", opts.event, phase).sort(
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 chain) {
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: ChainOutcome): Promise<boolean> {
165
- const chain = this.select("validation", opts.event, phase);
166
- const settled = await Promise.allSettled(chain.map((i) => this.invoke(i, phase, opts, out.payload)));
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 = chain[idx]!;
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
- const decision = (r.info?.[DECISION_INFO_KEY] as Decision | undefined) ?? "block";
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") out.blocked = { interceptor: i.definition.name, decision, reason };
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
  });