@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.
- package/README.md +61 -4
- package/dist/audit.d.ts +21 -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 +12 -3
- 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 +88 -15
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +1 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +8 -1
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +3 -2
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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 +1 -1
- package/dist/ledgerCli.d.ts.map +1 -1
- package/dist/ledgerCli.js +26 -6
- package/dist/ledgerCli.js.map +1 -1
- package/package.json +4 -4
- package/src/audit.ts +142 -2
- package/src/auditCli.ts +53 -0
- package/src/authorizer.ts +60 -0
- package/src/cli.ts +12 -3
- package/src/gateway.ts +95 -17
- package/src/hooks/claudeCode.ts +9 -2
- package/src/hooks/run.ts +3 -2
- package/src/index.ts +2 -0
- package/src/invoker.ts +24 -17
- 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
|
|
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, ...(label.ledger ? { ledger: label.ledger } : {}),
|
|
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;
|
|
@@ -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
|
});
|
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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.
|
|
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
|
});
|
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]
|
|
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>]
|
|
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 };
|