@adtl/gateway 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +97 -8
  2. package/dist/approvals.d.ts +22 -6
  3. package/dist/approvals.d.ts.map +1 -1
  4. package/dist/approvals.js +56 -26
  5. package/dist/approvals.js.map +1 -1
  6. package/dist/audit.d.ts +8 -0
  7. package/dist/audit.d.ts.map +1 -1
  8. package/dist/audit.js.map +1 -1
  9. package/dist/auditCli.d.ts +2 -0
  10. package/dist/auditCli.d.ts.map +1 -1
  11. package/dist/auditCli.js +13 -10
  12. package/dist/auditCli.js.map +1 -1
  13. package/dist/cli.js +17 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.d.ts +285 -21
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +49 -1
  18. package/dist/config.js.map +1 -1
  19. package/dist/elicitation.d.ts +25 -0
  20. package/dist/elicitation.d.ts.map +1 -0
  21. package/dist/elicitation.js +46 -0
  22. package/dist/elicitation.js.map +1 -0
  23. package/dist/gateway.d.ts +26 -1
  24. package/dist/gateway.d.ts.map +1 -1
  25. package/dist/gateway.js +85 -8
  26. package/dist/gateway.js.map +1 -1
  27. package/dist/hooks/claudeCode.d.ts +4 -0
  28. package/dist/hooks/claudeCode.d.ts.map +1 -1
  29. package/dist/hooks/claudeCode.js +8 -1
  30. package/dist/hooks/claudeCode.js.map +1 -1
  31. package/dist/hooks/run.d.ts.map +1 -1
  32. package/dist/hooks/run.js +6 -3
  33. package/dist/hooks/run.js.map +1 -1
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/init/presets.d.ts.map +1 -1
  39. package/dist/init/presets.js +54 -0
  40. package/dist/init/presets.js.map +1 -1
  41. package/dist/invoker.d.ts +21 -1
  42. package/dist/invoker.d.ts.map +1 -1
  43. package/dist/invoker.js +19 -2
  44. package/dist/invoker.js.map +1 -1
  45. package/dist/ledgerCli.d.ts +1 -1
  46. package/dist/ledgerCli.d.ts.map +1 -1
  47. package/dist/ledgerCli.js +9 -3
  48. package/dist/ledgerCli.js.map +1 -1
  49. package/dist/ntfy.d.ts +3 -1
  50. package/dist/ntfy.d.ts.map +1 -1
  51. package/dist/ntfy.js +2 -1
  52. package/dist/ntfy.js.map +1 -1
  53. package/dist/playgroundCli.d.ts +8 -0
  54. package/dist/playgroundCli.d.ts.map +1 -0
  55. package/dist/playgroundCli.js +57 -0
  56. package/dist/playgroundCli.js.map +1 -0
  57. package/dist/receipt.d.ts +62 -0
  58. package/dist/receipt.d.ts.map +1 -0
  59. package/dist/receipt.js +117 -0
  60. package/dist/receipt.js.map +1 -0
  61. package/dist/receiptCli.d.ts +8 -0
  62. package/dist/receiptCli.d.ts.map +1 -0
  63. package/dist/receiptCli.js +87 -0
  64. package/dist/receiptCli.js.map +1 -0
  65. package/dist/roles.d.ts +9 -0
  66. package/dist/roles.d.ts.map +1 -0
  67. package/dist/roles.js +41 -0
  68. package/dist/roles.js.map +1 -0
  69. package/dist/startup.d.ts +9 -0
  70. package/dist/startup.d.ts.map +1 -1
  71. package/dist/startup.js +69 -7
  72. package/dist/startup.js.map +1 -1
  73. package/package.json +5 -4
  74. package/playground/fake-github.mjs +98 -0
  75. package/playground/fake-memory.mjs +130 -0
  76. package/playground/index.html +411 -0
  77. package/playground/playground.yaml +40 -0
  78. package/playground/server.mjs +290 -0
  79. package/src/approvals.ts +63 -26
  80. package/src/audit.ts +5 -0
  81. package/src/auditCli.ts +11 -8
  82. package/src/cli.ts +16 -1
  83. package/src/config.ts +54 -1
  84. package/src/elicitation.ts +69 -0
  85. package/src/gateway.ts +96 -9
  86. package/src/hooks/claudeCode.ts +13 -2
  87. package/src/hooks/run.ts +6 -3
  88. package/src/index.ts +5 -0
  89. package/src/init/presets.ts +54 -0
  90. package/src/invoker.ts +39 -2
  91. package/src/ledgerCli.ts +9 -3
  92. package/src/ntfy.ts +4 -1
  93. package/src/playgroundCli.ts +60 -0
  94. package/src/receipt.ts +148 -0
  95. package/src/receiptCli.ts +85 -0
  96. package/src/roles.ts +55 -0
  97. package/src/startup.ts +68 -6
package/src/ledgerCli.ts CHANGED
@@ -17,6 +17,7 @@ import { userInfo } from "node:os";
17
17
  import { dirname, resolve } from "node:path";
18
18
  import { parseConfigText } from "@adtl/interceptors";
19
19
  import { Ledger, chunkHashes, defaultLedgerPath } from "@adtl/ledger";
20
+ import { defaultSigningKeyPath, loadSigner, trySigner } from "@adtl/labels";
20
21
 
21
22
  export const LEDGER_USAGE = `Usage: adtl-gateway ledger stats [-c <adtl.yaml>]
22
23
  adtl-gateway ledger verify [-c <adtl.yaml>]
@@ -28,7 +29,8 @@ private data, and re-labels it when it is read back. It lives in
28
29
  ledger.path from the config, else ADTL_LEDGER, else ~/.adtl/ledger.db.
29
30
 
30
31
  stats Count entries.
31
- verify Check that no entry was changed or removed (exit 1 if one was).
32
+ verify Check that no entry was changed or removed, and that signed
33
+ entries are signed by this machine's key (exit 1 if not).
32
34
  declassify Clear the label of each file's current content. Check the
33
35
  files first: after this, ADTL treats that content as clean.
34
36
  --reason is required and kept in the ledger.
@@ -68,7 +70,10 @@ export async function runLedgerCommand(argv: string[], env: NodeJS.ProcessEnv =
68
70
  if (parsed.ledger.path) path = resolve(dirname(abs), parsed.ledger.path);
69
71
  retentionDays = parsed.ledger.retentionDays;
70
72
  }
71
- const ledger = new Ledger(path, retentionDays ? { retentionDays } : {});
73
+ // Declassifying signs its entries; verifying only reads the key, to know whose signatures to trust.
74
+ const keyPath = defaultSigningKeyPath(env);
75
+ const signer = cmd === "declassify" ? trySigner(keyPath) : loadSigner(keyPath);
76
+ const ledger = new Ledger(path, { ...(retentionDays ? { retentionDays } : {}), ...(signer ? { signer } : {}) });
72
77
  try {
73
78
  if (cmd === "stats") {
74
79
  const s = ledger.stats();
@@ -76,8 +81,9 @@ export async function runLedgerCommand(argv: string[], env: NodeJS.ProcessEnv =
76
81
  }
77
82
  if (cmd === "verify") {
78
83
  const v = ledger.verify();
84
+ const sigs = v.signed ? `, ${v.signed} signed${v.unsigned ? `, ${v.unsigned} from before signing` : ""}` : "";
79
85
  return v.ok
80
- ? { stdout: `${path}: ${v.entries} entries, chain intact`, exitCode: 0 }
86
+ ? { stdout: `${path}: ${v.entries} entries${sigs}, chain intact`, exitCode: 0 }
81
87
  : { stdout: `${path}: TAMPERED: ${v.problem}`, exitCode: 1 };
82
88
  }
83
89
  if (cmd === "declassified") {
package/src/ntfy.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
13
13
 
14
14
  export interface NtfyOptions {
15
+ /** Channel name in approval results and logs. Default: "ntfy". */
16
+ name?: string;
15
17
  /** Server base URL, e.g. https://ntfy.sh */
16
18
  server: string;
17
19
  topic: string;
@@ -32,7 +34,7 @@ function describe(request: ApprovalRequest): string {
32
34
  }
33
35
 
34
36
  export class NtfyNotifier implements Notifier {
35
- readonly name = "ntfy";
37
+ readonly name: string;
36
38
  private readonly server: string;
37
39
  private readonly topic: string;
38
40
  private readonly replyTopic: string;
@@ -42,6 +44,7 @@ export class NtfyNotifier implements Notifier {
42
44
  private readonly publishAttempts: number;
43
45
 
44
46
  constructor(opts: NtfyOptions) {
47
+ this.name = opts.name ?? "ntfy";
45
48
  this.server = opts.server.replace(/\/+$/, "");
46
49
  this.topic = opts.topic;
47
50
  this.replyTopic = opts.replyTopic ?? `${opts.topic}-reply`;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `adtl-gateway playground [--port 4317] [--no-open]`: the web playground, with
3
+ * the real gateway in front of fake GitHub and memory servers. Listens on
4
+ * 127.0.0.1 only and opens the browser unless --no-open is given.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+
8
+ export const PLAYGROUND_USAGE = `Usage: adtl-gateway playground [--port <n>] [--no-open]
9
+
10
+ Runs the ADTL gateway in front of fake GitHub and memory servers, with a
11
+ browser UI: three test cases, every call's label and decision, the session
12
+ label, approvals, the config and the audit log. Nothing leaves your machine.
13
+
14
+ --port <n> Port on 127.0.0.1 (default 4317; 0 picks a free one).
15
+ --no-open Don't open the browser.`;
16
+
17
+ interface Playground {
18
+ url: string;
19
+ close(): Promise<void>;
20
+ }
21
+
22
+ export function parsePlaygroundArgs(argv: string[]): { port: number; open: boolean; help: boolean } {
23
+ let port = 4317;
24
+ let open = true;
25
+ let help = false;
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i]!;
28
+ if (a === "--port") port = Number(argv[++i]);
29
+ else if (a === "--no-open") open = false;
30
+ else if (a === "-h" || a === "--help") help = true;
31
+ else throw new Error(`Unknown argument: ${a}\n\n${PLAYGROUND_USAGE}`);
32
+ }
33
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`--port must be a number from 0 to 65535\n\n${PLAYGROUND_USAGE}`);
34
+ return { port, open, help };
35
+ }
36
+
37
+ function openBrowser(url: string): void {
38
+ const [cmd, args] =
39
+ process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
40
+ try {
41
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
42
+ } catch {
43
+ // No browser to open: the URL is printed anyway.
44
+ }
45
+ }
46
+
47
+ export async function runPlayground(argv: string[]): Promise<void> {
48
+ const opts = parsePlaygroundArgs(argv);
49
+ if (opts.help) return void console.log(PLAYGROUND_USAGE);
50
+ // Plain JavaScript shipped next to dist/, not compiled.
51
+ const mod = (await import(new URL("../playground/server.mjs", import.meta.url).href)) as {
52
+ startPlayground(o: { port: number }): Promise<Playground>;
53
+ };
54
+ const pg = await mod.startPlayground({ port: opts.port });
55
+ console.log(`ADTL playground: ${pg.url} (Ctrl-C to stop)`);
56
+ if (opts.open) openBrowser(pg.url);
57
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
58
+ process.once(sig, () => void pg.close().finally(() => process.exit(0)));
59
+ }
60
+ }
package/src/receipt.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Session receipts: a signed summary of every call a session made through
3
+ * the gateway and the Claude Code hooks, built from the hash-chained audit log.
4
+ *
5
+ * Agents can misreport what they did; the audit log is written outside the
6
+ * model. A receipt names the session, lists each call (tool, outcome,
7
+ * decision, approver, call hash, result label, and the hash of its audit log
8
+ * line) with counts, and is signed with the machine's Ed25519 key. Anyone with
9
+ * the public key can check it, and with the log at hand, that it lists every
10
+ * line the session wrote between its first and last call.
11
+ *
12
+ * Receipts cover what crossed the gateway and the hooks, not everything the
13
+ * agent did. Arguments and results are never included.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { verifyCanonical, signCanonical, type Signer } from "@adtl/labels";
17
+ import { verifyAuditLog, type AuditEntry } from "./audit.js";
18
+
19
+ export const RECEIPT_TYPE = "adtl.receipt.v1";
20
+
21
+ export interface ReceiptCall {
22
+ ts: string;
23
+ event: AuditEntry["event"];
24
+ server: string;
25
+ tool?: string;
26
+ resource?: string;
27
+ prompt?: string;
28
+ outcome: AuditEntry["outcome"];
29
+ decision?: string;
30
+ approver?: string;
31
+ callHash?: string;
32
+ /** Integrity and confidentiality of the result. */
33
+ label?: { integrity: string; confidentiality: string };
34
+ /** Hash of this call's audit log line. */
35
+ line: string;
36
+ }
37
+
38
+ export interface Receipt {
39
+ type: typeof RECEIPT_TYPE;
40
+ sessionId: string;
41
+ createdAt: string;
42
+ counts: { calls: number; forwarded: number; blocked: number; asked: number; upstreamErrors: number; approved: number; denied: number };
43
+ calls: ReceiptCall[];
44
+ /** Public key that signed it, `ed25519:…`. */
45
+ signer: string;
46
+ signature: string;
47
+ }
48
+
49
+ type ChainedEntry = AuditEntry & { hash: string };
50
+
51
+ function chainedEntries(logPath: string): ChainedEntry[] {
52
+ const v = verifyAuditLog(logPath);
53
+ if (!v.ok) throw new Error(`${logPath}: the audit log was tampered with (${v.problem}); refusing to issue a receipt`);
54
+ return readFileSync(logPath, "utf8")
55
+ .split("\n")
56
+ .filter((l) => l.trim())
57
+ .map((l) => JSON.parse(l) as Partial<ChainedEntry>)
58
+ .filter((e): e is ChainedEntry => typeof e.hash === "string" && typeof e.sessionId === "string");
59
+ }
60
+
61
+ /** Sessions in the audit log, with their number of calls and last activity. */
62
+ export function listSessions(logPath: string): { sessionId: string; calls: number; last: string }[] {
63
+ const out = new Map<string, { sessionId: string; calls: number; last: string }>();
64
+ for (const e of chainedEntries(logPath)) {
65
+ const s = out.get(e.sessionId) ?? { sessionId: e.sessionId, calls: 0, last: e.ts };
66
+ s.calls++;
67
+ if (e.ts > s.last) s.last = e.ts;
68
+ out.set(e.sessionId, s);
69
+ }
70
+ return [...out.values()].sort((a, b) => b.last.localeCompare(a.last));
71
+ }
72
+
73
+ function receiptCall(e: ChainedEntry): ReceiptCall {
74
+ const label = e.label as { integrity?: unknown; confidentiality?: unknown } | undefined;
75
+ return {
76
+ ts: e.ts,
77
+ event: e.event,
78
+ server: e.server,
79
+ ...(e.tool !== undefined ? { tool: e.tool } : {}),
80
+ ...(e.resource !== undefined ? { resource: e.resource } : {}),
81
+ ...(e.prompt !== undefined ? { prompt: e.prompt } : {}),
82
+ outcome: e.outcome,
83
+ ...(e.decision !== undefined ? { decision: e.decision } : {}),
84
+ ...(e.approval?.approver !== undefined ? { approver: e.approval.approver } : {}),
85
+ ...(typeof e.callHash === "string" ? { callHash: e.callHash } : {}),
86
+ ...(typeof label?.integrity === "string" && typeof label.confidentiality === "string"
87
+ ? { label: { integrity: label.integrity, confidentiality: label.confidentiality } }
88
+ : {}),
89
+ line: e.hash,
90
+ };
91
+ }
92
+
93
+ /** Build and sign the receipt for `sessionId` from a verified audit log. */
94
+ export function buildReceipt(logPath: string, sessionId: string, signer: Signer, now: Date = new Date()): Receipt {
95
+ const entries = chainedEntries(logPath).filter((e) => e.sessionId === sessionId);
96
+ if (entries.length === 0) throw new Error(`${logPath} has no entries for session ${sessionId}`);
97
+ const count = (f: (e: ChainedEntry) => boolean) => entries.filter(f).length;
98
+ const unsigned: Omit<Receipt, "signature"> = {
99
+ type: RECEIPT_TYPE,
100
+ sessionId,
101
+ createdAt: now.toISOString(),
102
+ counts: {
103
+ calls: entries.length,
104
+ forwarded: count((e) => e.outcome === "forwarded"),
105
+ blocked: count((e) => e.outcome === "blocked"),
106
+ asked: count((e) => e.outcome === "asked"),
107
+ upstreamErrors: count((e) => e.outcome === "upstream_error"),
108
+ approved: count((e) => e.approval?.outcome === "approved"),
109
+ denied: count((e) => e.approval?.outcome === "denied"),
110
+ },
111
+ calls: entries.map(receiptCall),
112
+ signer: signer.publicKey,
113
+ };
114
+ return { ...unsigned, signature: signCanonical(signer, unsigned) };
115
+ }
116
+
117
+ export interface ReceiptCheck {
118
+ ok: boolean;
119
+ problem?: string;
120
+ }
121
+
122
+ /**
123
+ * Check a receipt's signature, that it is signed by `trustedKey` if given,
124
+ * and, with `logPath`, that the log is intact and the receipt lists exactly
125
+ * the session's lines from its first call to its last.
126
+ */
127
+ export function verifyReceipt(receipt: Receipt, opts: { logPath?: string; trustedKey?: string } = {}): ReceiptCheck {
128
+ const { signature, ...unsigned } = receipt;
129
+ if (receipt.type !== RECEIPT_TYPE) return { ok: false, problem: `not an ADTL receipt (type ${String(receipt.type)})` };
130
+ if (!verifyCanonical(receipt.signer, unsigned, signature)) return { ok: false, problem: "the signature doesn't match: the receipt was changed or forged" };
131
+ if (opts.trustedKey && receipt.signer !== opts.trustedKey) return { ok: false, problem: `signed by ${receipt.signer}, not the expected key` };
132
+ if (!opts.logPath) return { ok: true };
133
+ let entries: ChainedEntry[];
134
+ try {
135
+ entries = chainedEntries(opts.logPath);
136
+ } catch (err) {
137
+ return { ok: false, problem: (err as Error).message };
138
+ }
139
+ const lines = receipt.calls.map((c) => c.line);
140
+ const first = entries.findIndex((e) => e.hash === lines[0]);
141
+ const last = entries.findIndex((e) => e.hash === lines.at(-1));
142
+ if (first < 0 || last < 0) return { ok: false, problem: "the log doesn't contain the receipt's calls" };
143
+ const logged = entries.slice(first, last + 1).filter((e) => e.sessionId === receipt.sessionId);
144
+ if (logged.length !== lines.length || logged.some((e, i) => e.hash !== lines[i])) {
145
+ return { ok: false, problem: "the receipt doesn't list exactly the session's calls in the log" };
146
+ }
147
+ return { ok: true };
148
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `adtl-gateway receipt` and `adtl-gateway key`.
3
+ *
4
+ * adtl-gateway receipt list [<log>] [-c adtl.yaml]
5
+ * adtl-gateway receipt <session> [<log>] [-c adtl.yaml] [-o receipt.json]
6
+ * adtl-gateway receipt verify <receipt.json> [--log <log>] [--key ed25519:…]
7
+ * adtl-gateway key
8
+ */
9
+ import { readFile, writeFile } from "node:fs/promises";
10
+ import { defaultSigningKeyPath, loadOrCreateSigner } from "@adtl/labels";
11
+ import { resolveAuditPath } from "./auditCli.js";
12
+ import { buildReceipt, listSessions, verifyReceipt, type Receipt } from "./receipt.js";
13
+
14
+ export const RECEIPT_USAGE = `Usage: adtl-gateway receipt list [<log>] [-c <adtl.yaml>]
15
+ adtl-gateway receipt <session> [<log>] [-c <adtl.yaml>] [-o <receipt.json>]
16
+ adtl-gateway receipt verify <receipt.json> [--log <log>] [--key <ed25519:...>]
17
+ adtl-gateway key
18
+
19
+ A receipt is a signed summary of every call one session made through the
20
+ gateway and the Claude Code hooks: tool, outcome, decision, approver, call
21
+ hash, result label and the hash of each audit log line. It is built from the
22
+ hash-chained audit log (gateway.audit) and signed with this machine's key
23
+ (ADTL_SIGNING_KEY, or ~/.adtl/signing-key.pem).
24
+
25
+ list Sessions in the log, newest first.
26
+ verify Check the signature; with --log, also that the receipt lists every
27
+ line the session wrote; with --key, who signed it (exit 1 if not).
28
+ key Print this machine's public key, to give to whoever checks receipts.`;
29
+
30
+ export interface ReceiptCommandResult {
31
+ stdout: string;
32
+ exitCode: number;
33
+ }
34
+
35
+ export async function runKeyCommand(env: NodeJS.ProcessEnv = process.env): Promise<ReceiptCommandResult> {
36
+ const path = defaultSigningKeyPath(env);
37
+ const signer = loadOrCreateSigner(path);
38
+ return { stdout: `${signer.publicKey}\nkey id: ${signer.keyId}\nfile: ${path}`, exitCode: 0 };
39
+ }
40
+
41
+ export async function runReceiptCommand(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<ReceiptCommandResult> {
42
+ const [cmd, ...rest] = argv;
43
+ if (cmd === undefined || cmd === "-h" || cmd === "--help") return { stdout: RECEIPT_USAGE, exitCode: 0 };
44
+ let config: string | undefined;
45
+ let out: string | undefined;
46
+ let log: string | undefined;
47
+ let key: string | undefined;
48
+ const args: string[] = [];
49
+ for (let i = 0; i < rest.length; i++) {
50
+ const a = rest[i]!;
51
+ if (a === "-c" || a === "--config") config = rest[++i];
52
+ else if (a === "-o" || a === "--out") out = rest[++i];
53
+ else if (a === "--log") log = rest[++i];
54
+ else if (a === "--key") key = rest[++i];
55
+ else if (a.startsWith("-")) throw new Error(`Unknown argument: ${a}\n\n${RECEIPT_USAGE}`);
56
+ else args.push(a);
57
+ }
58
+
59
+ if (cmd === "verify") {
60
+ if (args.length !== 1) throw new Error(`receipt verify needs one receipt file\n\n${RECEIPT_USAGE}`);
61
+ const receipt = JSON.parse(await readFile(args[0]!, "utf8")) as Receipt;
62
+ const r = verifyReceipt(receipt, { ...(log ? { logPath: log } : {}), ...(key ? { trustedKey: key } : {}) });
63
+ if (!r.ok) return { stdout: `${args[0]}: INVALID: ${r.problem}`, exitCode: 1 };
64
+ const checked = [`signed by ${receipt.signer}`, ...(key ? ["the expected key"] : []), ...(log ? ["matches the log"] : [])];
65
+ return { stdout: `${args[0]}: valid (${checked.join(", ")}): ${receipt.counts.calls} calls in session ${receipt.sessionId}`, exitCode: 0 };
66
+ }
67
+
68
+ if (cmd === "list") {
69
+ if (args.length > 1) throw new Error(`Unexpected argument: ${args[1]}\n\n${RECEIPT_USAGE}`);
70
+ const path = await resolveAuditPath(args[0], config, RECEIPT_USAGE);
71
+ const sessions = listSessions(path);
72
+ if (sessions.length === 0) return { stdout: `${path}: no sessions`, exitCode: 0 };
73
+ return { stdout: sessions.map((s) => `${s.last} ${s.sessionId} ${s.calls} call${s.calls === 1 ? "" : "s"}`).join("\n"), exitCode: 0 };
74
+ }
75
+
76
+ if (args.length > 1) throw new Error(`Unexpected argument: ${args[1]}\n\n${RECEIPT_USAGE}`);
77
+ const path = await resolveAuditPath(args[0], config, RECEIPT_USAGE);
78
+ const receipt = buildReceipt(path, cmd, loadOrCreateSigner(defaultSigningKeyPath(env)));
79
+ const json = `${JSON.stringify(receipt, null, 2)}\n`;
80
+ if (out) {
81
+ await writeFile(out, json);
82
+ return { stdout: `${out}: receipt for ${receipt.counts.calls} calls in session ${cmd}`, exitCode: 0 };
83
+ }
84
+ return { stdout: json.trimEnd(), exitCode: 0 };
85
+ }
package/src/roles.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Role-based authorization from the config (`gateway.roles`):
3
+ *
4
+ * roles:
5
+ * maintainers: { members: [alice, bob], tools: ["github.*"] }
6
+ * readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
7
+ *
8
+ * A call is allowed if a role the caller belongs to grants it: `tools` and
9
+ * `prompts` are `server.name` patterns (the same `*` wildcards as policy
10
+ * rules), `resources` are URI patterns. Everything else is denied. Members
11
+ * are principal ids; "*" means everyone, including an anonymous caller.
12
+ */
13
+ import { globToRegExp } from "@adtl/labels";
14
+ import type { AuthorizationDecision, AuthorizationRequest, Authorizer } from "./authorizer.js";
15
+ import type { RoleConfig } from "./config.js";
16
+
17
+ interface CompiledRole {
18
+ name: string;
19
+ members: Set<string>;
20
+ tools: RegExp[];
21
+ prompts: RegExp[];
22
+ resources: RegExp[];
23
+ }
24
+
25
+ export class RoleAuthorizer implements Authorizer {
26
+ readonly name = "roles";
27
+ private readonly roles: CompiledRole[];
28
+
29
+ constructor(roles: Record<string, RoleConfig>) {
30
+ this.roles = Object.entries(roles).map(([name, r]) => ({
31
+ name,
32
+ members: new Set(r.members),
33
+ tools: r.tools.map(globToRegExp),
34
+ prompts: r.prompts.map(globToRegExp),
35
+ resources: r.resources.map(globToRegExp),
36
+ }));
37
+ }
38
+
39
+ check(req: AuthorizationRequest): AuthorizationDecision {
40
+ const id = req.principal.id;
41
+ const [kind, what] =
42
+ req.event === "tools/call"
43
+ ? (["tools", `${req.server}.${req.tool}`] as const)
44
+ : req.event === "prompts/get"
45
+ ? (["prompts", `${req.server}.${req.prompt}`] as const)
46
+ : (["resources", req.resource ?? ""] as const);
47
+ const granted = this.roles.some(
48
+ (r) => (r.members.has("*") || (id !== undefined && r.members.has(id))) && r[kind].some((p) => p.test(what)),
49
+ );
50
+ if (granted) return { allow: true };
51
+ const who = id ?? "an anonymous caller";
52
+ const noun = kind === "tools" ? "tool" : kind === "prompts" ? "prompt" : "resource";
53
+ return { allow: false, reason: `No role lets ${who} use the ${noun} ${what}` };
54
+ }
55
+ }
package/src/startup.ts CHANGED
@@ -9,14 +9,16 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
9
  import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
10
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
11
  import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseConfigText } from "@adtl/interceptors";
12
- import { defaultSessionsDir, type AdtlConfig } from "@adtl/labels";
12
+ import { SIGNING_KEY_ENV, defaultSessionsDir, type AdtlConfig } from "@adtl/labels";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { ZodError } from "zod";
15
15
  import { ApprovalBroker, type Notifier } from "./approvals.js";
16
16
  import { JsonlAuditLog } from "./audit.js";
17
+ import type { Principal } from "./authorizer.js";
17
18
  import {
18
19
  GatewayConfigSchema,
19
20
  expandApprovals,
21
+ expandEnv,
20
22
  expandUpstream,
21
23
  isHttpUpstream,
22
24
  type GatewayConfig,
@@ -27,6 +29,7 @@ import { EmailNotifier } from "./email.js";
27
29
  import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
28
30
  import { InterceptorInvoker } from "./invoker.js";
29
31
  import { NtfyNotifier } from "./ntfy.js";
32
+ import { RoleAuthorizer } from "./roles.js";
30
33
 
31
34
  export class GatewayConfigError extends Error {
32
35
  constructor(message: string) {
@@ -117,10 +120,54 @@ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log:
117
120
  if (a.email) {
118
121
  notifiers.push(new EmailNotifier({ smtp: a.email.smtp, from: a.email.from, to: [a.email.to].flat(), on: a.email.on }));
119
122
  }
120
- if (notifiers.length === 0) return undefined;
121
- if (!notifiers.some((n) => n.listen)) log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
122
- log.info(`approvals: ${notifiers.map((n) => n.name).join(", ")} (timeout ${a.timeoutSeconds}s)`);
123
- return new ApprovalBroker({ notifiers, timeoutMs: a.timeoutSeconds * 1000, log });
123
+ const approverChannels = new Map<string, Notifier[]>(
124
+ Object.entries(a.approvers ?? {}).map(([id, c]) => [id, [new NtfyNotifier({ ...c.ntfy, name: `ntfy:${id}` })]]),
125
+ );
126
+ if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation) return undefined;
127
+ if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation) {
128
+ log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
129
+ }
130
+ const approvers = approverChannels.size ? `; approvers: ${[...approverChannels.keys()].join(", ")}` : "";
131
+ const shared = [...notifiers.map((n) => n.name), ...(a.elicitation ? ["elicitation (if the client supports it)"] : [])];
132
+ log.info(`approvals: ${shared.join(", ") || "none shared"}${approvers} (timeout ${a.timeoutSeconds}s)`);
133
+ return new ApprovalBroker({ notifiers, approverChannels, timeoutMs: a.timeoutSeconds * 1000, log });
134
+ }
135
+
136
+ /**
137
+ * Check that every role a policy rule names under `approvers:` exists, has
138
+ * named members, and that each member has a channel in
139
+ * `gateway.approvals.approvers`. Returns each such role's members.
140
+ */
141
+ export function approverRoles(labels: AdtlConfig, cfg: GatewayConfig): Map<string, string[]> {
142
+ const out = new Map<string, string[]>();
143
+ const problems: string[] = [];
144
+ for (const rule of labels.policy.rules) {
145
+ for (const role of rule.approvers ?? []) {
146
+ if (out.has(role)) continue;
147
+ const r = cfg.roles?.[role];
148
+ if (!r) {
149
+ problems.push(`policy rule "${rule.name}" names approver role "${role}", which is not under gateway.roles`);
150
+ continue;
151
+ }
152
+ const members = [...r.members];
153
+ if (members.includes("*")) problems.push(`approver role "${role}" has member "*": approvers must be named, so the answer says who approved`);
154
+ for (const m of members.filter((x) => x !== "*")) {
155
+ if (!cfg.approvals?.approvers?.[m]) problems.push(`approver "${m}" (role "${role}") has no channel under gateway.approvals.approvers`);
156
+ }
157
+ out.set(role, members);
158
+ }
159
+ }
160
+ if (problems.length) throw new GatewayConfigError(`approvers:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
161
+ return out;
162
+ }
163
+
164
+ /** `gateway.principal`, with `${VAR}` in its id expanded. */
165
+ export function gatewayPrincipal(cfg: GatewayConfig, env: NodeJS.ProcessEnv): Principal | undefined {
166
+ const p = cfg.principal;
167
+ if (!p) return undefined;
168
+ const id = expandEnv(p.id, env, "gateway.principal.id").trim();
169
+ if (!id) throw new GatewayConfigError("gateway.principal.id is empty after expanding environment variables");
170
+ return { type: p.type, id, ...(p.claims ? { claims: p.claims } : {}) };
124
171
  }
125
172
 
126
173
  /**
@@ -143,6 +190,8 @@ export async function startGateway(
143
190
  opts: { env?: NodeJS.ProcessEnv; logger?: GatewayLogger } = {},
144
191
  ): Promise<StartedGateway> {
145
192
  const env = opts.env ?? process.env;
193
+ // Config problems that need no process: fail before starting any.
194
+ const approverMembers = approverRoles(loaded.labels, loaded.gateway);
146
195
  const baseDir = dirname(loaded.path);
147
196
  const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
148
197
  const clients: Client[] = [];
@@ -153,7 +202,10 @@ export async function startGateway(
153
202
  const shared = sharedSession(env);
154
203
  const custom = loaded.gateway.interceptors && expandUpstream("interceptors", loaded.gateway.interceptors, env);
155
204
  // The interceptor process starts with a minimal environment; pass on where the ledger lives.
156
- const ledgerEnv: Record<string, string> = env.ADTL_LEDGER ? { ADTL_LEDGER: env.ADTL_LEDGER } : {};
205
+ const ledgerEnv: Record<string, string> = {
206
+ ...(env.ADTL_LEDGER ? { ADTL_LEDGER: env.ADTL_LEDGER } : {}),
207
+ ...(env[SIGNING_KEY_ENV] ? { [SIGNING_KEY_ENV]: env[SIGNING_KEY_ENV]! } : {}),
208
+ };
157
209
  const icfg: StdioUpstream = custom
158
210
  ? { ...custom, env: { ...ledgerEnv, ...custom.env, ...(shared ? { ADTL_SESSIONS_DIR: shared.dir } : {}) } }
159
211
  : {
@@ -202,12 +254,22 @@ export async function startGateway(
202
254
 
203
255
  const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
204
256
  const approvals = createApprovals(loaded.gateway, env, log);
257
+ const principal = gatewayPrincipal(loaded.gateway, env);
258
+ const roles = loaded.gateway.roles;
259
+ if (roles) {
260
+ log.info(`roles: ${Object.keys(roles).join(", ")} (caller: ${principal?.id ?? "anonymous"})`);
261
+ if (!principal) log.warn('roles are set but gateway.principal is not: only roles with members ["*"] apply');
262
+ }
205
263
  const gateway = new AdtlGateway({
206
264
  ...(shared ? { sessionId: shared.sessionId } : {}),
207
265
  upstreams,
208
266
  invoker,
209
267
  ...(auditLog ? { audit: auditLog } : {}),
210
268
  ...(approvals ? { approvals } : {}),
269
+ ...(roles ? { authorizer: new RoleAuthorizer(roles) } : {}),
270
+ ...(principal ? { principal } : {}),
271
+ ...(approverMembers.size ? { approverRoles: approverMembers } : {}),
272
+ ...(loaded.gateway.approvals?.elicitation ? { elicitApprovals: true } : {}),
211
273
  logger: log,
212
274
  });
213
275
  return { gateway, close: closeAll };