@adtl/gateway 0.4.0 → 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 (49) hide show
  1. package/README.md +61 -4
  2. package/dist/audit.d.ts +21 -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 +12 -3
  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 +88 -15
  19. package/dist/gateway.js.map +1 -1
  20. package/dist/hooks/claudeCode.d.ts +1 -0
  21. package/dist/hooks/claudeCode.d.ts.map +1 -1
  22. package/dist/hooks/claudeCode.js +8 -1
  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 +3 -2
  26. package/dist/hooks/run.js.map +1 -1
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -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 +1 -1
  36. package/dist/ledgerCli.d.ts.map +1 -1
  37. package/dist/ledgerCli.js +26 -6
  38. package/dist/ledgerCli.js.map +1 -1
  39. package/package.json +4 -4
  40. package/src/audit.ts +142 -2
  41. package/src/auditCli.ts +53 -0
  42. package/src/authorizer.ts +60 -0
  43. package/src/cli.ts +12 -3
  44. package/src/gateway.ts +95 -17
  45. package/src/hooks/claudeCode.ts +9 -2
  46. package/src/hooks/run.ts +3 -2
  47. package/src/index.ts +2 -0
  48. package/src/invoker.ts +24 -17
  49. package/src/ledgerCli.ts +22 -6
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.4.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: 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, ...(label.ledger ? { ledger: label.ledger } : {}), 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;
@@ -494,6 +571,7 @@ export class AdtlGateway {
494
571
  callHash: policy.callHash,
495
572
  label: label.label,
496
573
  ...(label.ledger ? { ledger: label.ledger } : {}),
574
+ ...(label.records ? { records: label.records } : {}),
497
575
  sessionLabel: label.sessionLabel,
498
576
  warnings: [...req.warnings, ...res.warnings],
499
577
  });
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * Errors fail closed: a PreToolUse hook that can't decide denies the call.
26
26
  */
27
- import { isAbsolute, relative, resolve, sep } from "node:path";
27
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
28
28
  import {
29
29
  callHash,
30
30
  evaluatePolicy,
@@ -62,7 +62,7 @@ export interface HookOptions {
62
62
  /** Label ledger shared with the gateway. */
63
63
  ledger?: Ledger;
64
64
  /** Files the agent must not change: the ADTL config, the session directory and the ledger. */
65
- protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string };
65
+ protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string };
66
66
  }
67
67
 
68
68
  /** What the hook process prints and exits with. */
@@ -128,6 +128,10 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
128
128
  if (protect.ledgerPath && abs.startsWith(resolve(protect.ledgerPath))) {
129
129
  return { decision: "block", reason: "The agent may not change the ADTL ledger" };
130
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
+ }
131
135
  }
132
136
  const command = typeof args.command === "string" ? args.command : undefined;
133
137
  if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
@@ -139,6 +143,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
139
143
  if (ledgerMarks.some((m) => command.includes(m))) {
140
144
  return { decision: "ask", reason: "This command touches the ADTL ledger" };
141
145
  }
146
+ if (protect.auditPath && command.includes(basename(protect.auditPath))) {
147
+ return { decision: "ask", reason: "This command touches the ADTL audit log" };
148
+ }
142
149
  }
143
150
  return undefined;
144
151
  }
package/src/hooks/run.ts CHANGED
@@ -39,14 +39,15 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
39
39
  const sessionsDir = defaultSessionsDir(env);
40
40
  const ledgerPath = config.ledger.path ? resolve(dirname(path), config.ledger.path) : defaultLedgerPath(env);
41
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;
42
43
  return handleHook(input, {
43
44
  config,
44
45
  sessions: new FileSessionStore(sessionsDir),
45
46
  ...(ledger ? { ledger } : {}),
46
47
  ...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
47
48
  mode: gw.mode === "audit" ? "audit" : "active",
48
- ...(typeof gw.audit === "string" ? { audit: new JsonlAuditLog(resolve(dirname(path), gw.audit)) } : {}),
49
- protect: { configPath: path, sessionsDir, ledgerPath },
49
+ ...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
50
+ protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}) },
50
51
  });
51
52
  } catch (err) {
52
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";
@@ -9,3 +10,4 @@ export * from "./startup.js";
9
10
  export * from "./init/index.js";
10
11
  export * from "./hooks/index.js";
11
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
  });
package/src/ledgerCli.ts CHANGED
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * adtl-gateway ledger stats [-c adtl.yaml]
5
5
  * adtl-gateway ledger verify [-c adtl.yaml]
6
- * adtl-gateway ledger declassify <file>... [-c adtl.yaml] [--reason <text>]
6
+ * adtl-gateway ledger declassify <file>... [-c adtl.yaml] --reason <text>
7
+ * adtl-gateway ledger declassified [-c adtl.yaml] [--limit <n>]
7
8
  *
8
9
  * `declassify` clears the label of each file's current content, after you've
9
10
  * checked it. It is meant to be run by a person; the Claude Code hook asks
10
- * before letting the agent run it.
11
+ * before letting the agent run it. The reason is required: declassifying is
12
+ * where taint tracking erodes, and the reason is what a later review reads.
13
+ * `declassified` lists recent declassifications for that review.
11
14
  */
12
15
  import { readFile } from "node:fs/promises";
13
16
  import { userInfo } from "node:os";
@@ -17,7 +20,8 @@ import { Ledger, chunkHashes, defaultLedgerPath } from "@adtl/ledger";
17
20
 
18
21
  export const LEDGER_USAGE = `Usage: adtl-gateway ledger stats [-c <adtl.yaml>]
19
22
  adtl-gateway ledger verify [-c <adtl.yaml>]
20
- adtl-gateway ledger declassify <file>... [-c <adtl.yaml>] [--reason <text>]
23
+ adtl-gateway ledger declassify <file>... [-c <adtl.yaml>] --reason <text>
24
+ adtl-gateway ledger declassified [-c <adtl.yaml>] [--limit <n>]
21
25
 
22
26
  The ledger remembers content written by sessions that had read untrusted or
23
27
  private data, and re-labels it when it is read back. It lives in
@@ -26,7 +30,9 @@ ledger.path from the config, else ADTL_LEDGER, else ~/.adtl/ledger.db.
26
30
  stats Count entries.
27
31
  verify Check that no entry was changed or removed (exit 1 if one was).
28
32
  declassify Clear the label of each file's current content. Check the
29
- files first: after this, ADTL treats that content as clean.`;
33
+ files first: after this, ADTL treats that content as clean.
34
+ --reason is required and kept in the ledger.
35
+ declassified List recent declassifications: when, who, what and why.`;
30
36
 
31
37
  export interface LedgerCommandResult {
32
38
  stdout: string;
@@ -37,17 +43,21 @@ export async function runLedgerCommand(argv: string[], env: NodeJS.ProcessEnv =
37
43
  const [cmd, ...rest] = argv;
38
44
  let config: string | undefined;
39
45
  let reason: string | undefined;
46
+ let limit: number | undefined;
40
47
  const files: string[] = [];
41
48
  for (let i = 0; i < rest.length; i++) {
42
49
  const a = rest[i]!;
43
50
  if (a === "-c" || a === "--config") config = rest[++i];
44
51
  else if (a === "--reason") reason = rest[++i];
52
+ else if (a === "--limit") limit = Number(rest[++i]);
45
53
  else if (a.startsWith("-")) throw new Error(`Unknown argument: ${a}\n\n${LEDGER_USAGE}`);
46
54
  else files.push(a);
47
55
  }
48
56
  if (cmd === undefined || cmd === "-h" || cmd === "--help") return { stdout: LEDGER_USAGE, exitCode: 0 };
49
- if (!["stats", "verify", "declassify"].includes(cmd)) throw new Error(`Unknown ledger command: ${cmd}\n\n${LEDGER_USAGE}`);
57
+ if (!["stats", "verify", "declassify", "declassified"].includes(cmd)) throw new Error(`Unknown ledger command: ${cmd}\n\n${LEDGER_USAGE}`);
50
58
  if (cmd === "declassify" && files.length === 0) throw new Error(`declassify needs at least one file\n\n${LEDGER_USAGE}`);
59
+ if (cmd === "declassify" && !reason?.trim()) throw new Error(`declassify needs --reason: say why this content is safe\n\n${LEDGER_USAGE}`);
60
+ if (limit !== undefined && !(Number.isInteger(limit) && limit > 0)) throw new Error(`--limit must be a positive whole number\n\n${LEDGER_USAGE}`);
51
61
  if (cmd !== "declassify" && files.length > 0) throw new Error(`Unexpected argument: ${files[0]}\n\n${LEDGER_USAGE}`);
52
62
 
53
63
  let path = defaultLedgerPath(env);
@@ -70,10 +80,16 @@ export async function runLedgerCommand(argv: string[], env: NodeJS.ProcessEnv =
70
80
  ? { stdout: `${path}: ${v.entries} entries, chain intact`, exitCode: 0 }
71
81
  : { stdout: `${path}: TAMPERED: ${v.problem}`, exitCode: 1 };
72
82
  }
83
+ if (cmd === "declassified") {
84
+ const list = ledger.declassifications(limit);
85
+ if (list.length === 0) return { stdout: `${path}: nothing declassified`, exitCode: 0 };
86
+ const rows = list.map((d) => `${d.ts} ${d.by} ${d.subject ?? "-"} ${d.chunks} chunk${d.chunks === 1 ? "" : "s"} ${d.reason ?? "(no reason given)"}`);
87
+ return { stdout: rows.join("\n"), exitCode: 0 };
88
+ }
73
89
  const by = `user:${userInfo().username}`;
74
90
  const lines: string[] = [];
75
91
  for (const f of files) {
76
- const n = ledger.declassify(chunkHashes(await readFile(resolve(f), "utf8")), by, reason);
92
+ const n = ledger.declassify(chunkHashes(await readFile(resolve(f), "utf8")), by, reason!.trim(), f);
77
93
  lines.push(n ? `${f}: cleared ${n} labeled chunk${n === 1 ? "" : "s"}` : `${f}: nothing labeled`);
78
94
  }
79
95
  return { stdout: lines.join("\n"), exitCode: 0 };