@adtl/gateway 0.5.1 → 0.7.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 +152 -9
- package/dist/approvals.d.ts +22 -6
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +56 -26
- package/dist/approvals.js.map +1 -1
- package/dist/audit.d.ts +8 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/auditCli.d.ts +2 -0
- package/dist/auditCli.d.ts.map +1 -1
- package/dist/auditCli.js +13 -10
- package/dist/auditCli.js.map +1 -1
- package/dist/cedar.d.ts +27 -0
- package/dist/cedar.d.ts.map +1 -0
- package/dist/cedar.js +134 -0
- package/dist/cedar.js.map +1 -0
- package/dist/cli.js +17 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +330 -21
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +74 -1
- package/dist/config.js.map +1 -1
- package/dist/elicitation.d.ts +25 -0
- package/dist/elicitation.d.ts.map +1 -0
- package/dist/elicitation.js +46 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/gateway.d.ts +30 -5
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +96 -14
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +4 -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 +6 -3
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/init/presets.d.ts.map +1 -1
- package/dist/init/presets.js +54 -0
- package/dist/init/presets.js.map +1 -1
- package/dist/invoker.d.ts +21 -1
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +19 -2
- 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 +9 -3
- package/dist/ledgerCli.js.map +1 -1
- package/dist/ntfy.d.ts +3 -1
- package/dist/ntfy.d.ts.map +1 -1
- package/dist/ntfy.js +2 -1
- package/dist/ntfy.js.map +1 -1
- package/dist/playgroundCli.d.ts +8 -0
- package/dist/playgroundCli.d.ts.map +1 -0
- package/dist/playgroundCli.js +57 -0
- package/dist/playgroundCli.js.map +1 -0
- package/dist/receipt.d.ts +62 -0
- package/dist/receipt.d.ts.map +1 -0
- package/dist/receipt.js +117 -0
- package/dist/receipt.js.map +1 -0
- package/dist/receiptCli.d.ts +8 -0
- package/dist/receiptCli.d.ts.map +1 -0
- package/dist/receiptCli.js +87 -0
- package/dist/receiptCli.js.map +1 -0
- package/dist/roles.d.ts +9 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +41 -0
- package/dist/roles.js.map +1 -0
- package/dist/startup.d.ts +14 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +92 -7
- package/dist/startup.js.map +1 -1
- package/package.json +16 -4
- package/playground/fake-github.mjs +98 -0
- package/playground/fake-memory.mjs +130 -0
- package/playground/index.html +411 -0
- package/playground/playground.yaml +40 -0
- package/playground/server.mjs +290 -0
- package/src/approvals.ts +63 -26
- package/src/audit.ts +5 -0
- package/src/auditCli.ts +11 -8
- package/src/cedar.ts +140 -0
- package/src/cli.ts +16 -1
- package/src/config.ts +80 -1
- package/src/elicitation.ts +69 -0
- package/src/gateway.ts +108 -17
- package/src/hooks/claudeCode.ts +13 -2
- package/src/hooks/run.ts +6 -3
- package/src/index.ts +6 -0
- package/src/init/presets.ts +54 -0
- package/src/invoker.ts +39 -2
- package/src/ledgerCli.ts +9 -3
- package/src/ntfy.ts +4 -1
- package/src/playgroundCli.ts +60 -0
- package/src/receipt.ts +148 -0
- package/src/receiptCli.ts +85 -0
- package/src/roles.ts +55 -0
- package/src/startup.ts +88 -6
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface HookInput {
|
|
|
49
49
|
/** PostToolUseFailure: the error the tool returned. */
|
|
50
50
|
error?: unknown;
|
|
51
51
|
cwd?: string;
|
|
52
|
+
/** Set when the hook fires inside a subagent (or an in-process teammate). */
|
|
53
|
+
agent_id?: string;
|
|
54
|
+
agent_type?: string;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
export interface HookOptions {
|
|
@@ -62,7 +65,7 @@ export interface HookOptions {
|
|
|
62
65
|
/** Label ledger shared with the gateway. */
|
|
63
66
|
ledger?: Ledger;
|
|
64
67
|
/** Files the agent must not change: the ADTL config, the session directory and the ledger. */
|
|
65
|
-
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string };
|
|
68
|
+
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/** What the hook process prints and exits with. */
|
|
@@ -132,6 +135,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
132
135
|
if (protect.auditPath && abs.startsWith(resolve(protect.auditPath))) {
|
|
133
136
|
return { decision: "block", reason: "The agent may not change the ADTL audit log" };
|
|
134
137
|
}
|
|
138
|
+
if (protect.signingKeyPath && abs === resolve(protect.signingKeyPath)) {
|
|
139
|
+
return { decision: "block", reason: "The agent may not touch the ADTL signing key" };
|
|
140
|
+
}
|
|
135
141
|
}
|
|
136
142
|
const command = typeof args.command === "string" ? args.command : undefined;
|
|
137
143
|
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
@@ -146,6 +152,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
146
152
|
if (protect.auditPath && command.includes(basename(protect.auditPath))) {
|
|
147
153
|
return { decision: "ask", reason: "This command touches the ADTL audit log" };
|
|
148
154
|
}
|
|
155
|
+
if (protect.signingKeyPath && command.includes(basename(protect.signingKeyPath))) {
|
|
156
|
+
return { decision: "ask", reason: "This command touches the ADTL signing key" };
|
|
157
|
+
}
|
|
149
158
|
}
|
|
150
159
|
return undefined;
|
|
151
160
|
}
|
|
@@ -173,7 +182,9 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
173
182
|
if (!route) return NOTHING;
|
|
174
183
|
const config = withClaudeCodePreset(opts.config);
|
|
175
184
|
const ts = new Date().toISOString();
|
|
176
|
-
const
|
|
185
|
+
const agent =
|
|
186
|
+
typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
|
|
187
|
+
const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
|
|
177
188
|
|
|
178
189
|
if (event !== "PreToolUse") {
|
|
179
190
|
const output = input.tool_response ?? input.error ?? null;
|
package/src/hooks/run.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
|
-
import { FileSessionStore, defaultSessionsDir } from "@adtl/labels";
|
|
11
|
+
import { FileSessionStore, defaultSessionsDir, defaultSigningKeyPath, trySigner } from "@adtl/labels";
|
|
12
12
|
import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
13
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
@@ -38,7 +38,10 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
38
38
|
const env = opts.env ?? process.env;
|
|
39
39
|
const sessionsDir = defaultSessionsDir(env);
|
|
40
40
|
const ledgerPath = config.ledger.path ? resolve(dirname(path), config.ledger.path) : defaultLedgerPath(env);
|
|
41
|
-
const
|
|
41
|
+
const signer = config.ledger.enabled ? trySigner(defaultSigningKeyPath(env)) : undefined;
|
|
42
|
+
const ledger = config.ledger.enabled
|
|
43
|
+
? new Ledger(ledgerPath, { retentionDays: config.ledger.retentionDays, ...(signer ? { signer } : {}) })
|
|
44
|
+
: undefined;
|
|
42
45
|
const auditPath = typeof gw.audit === "string" ? resolve(dirname(path), gw.audit) : undefined;
|
|
43
46
|
return handleHook(input, {
|
|
44
47
|
config,
|
|
@@ -47,7 +50,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
47
50
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
48
51
|
mode: gw.mode === "audit" ? "audit" : "active",
|
|
49
52
|
...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
|
|
50
|
-
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}) },
|
|
53
|
+
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env) },
|
|
51
54
|
});
|
|
52
55
|
} catch (err) {
|
|
53
56
|
return failure(input.hook_event_name, (err as Error).message);
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,9 @@ export * from "./init/index.js";
|
|
|
11
11
|
export * from "./hooks/index.js";
|
|
12
12
|
export * from "./ledgerCli.js";
|
|
13
13
|
export * from "./auditCli.js";
|
|
14
|
+
export * from "./roles.js";
|
|
15
|
+
export * from "./cedar.js";
|
|
16
|
+
export * from "./elicitation.js";
|
|
17
|
+
export * from "./receipt.js";
|
|
18
|
+
export * from "./receiptCli.js";
|
|
19
|
+
export * from "./playgroundCli.js";
|
package/src/init/presets.ts
CHANGED
|
@@ -53,6 +53,60 @@ export const PRESETS: Preset[] = [
|
|
|
53
53
|
write: { integrity: "untrusted", confidentiality: "public", egress: "external" },
|
|
54
54
|
note: "preset fetch: web pages are untrusted; a fetched URL can carry data out, so fetch is external egress",
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
id: "slack",
|
|
58
|
+
// Both popular servers report this name: @modelcontextprotocol/server-slack (no tool hints, so its
|
|
59
|
+
// tools are listed below) and korotovsky/slack-mcp-server (marks reads with readOnlyHint).
|
|
60
|
+
serverNames: ["Slack MCP Server"],
|
|
61
|
+
// Anyone in the workspace, or a shared channel, can write messages and profiles.
|
|
62
|
+
defaults: { integrity: "untrusted", confidentiality: "private", egress: "external" },
|
|
63
|
+
read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
64
|
+
write: { egress: "external" },
|
|
65
|
+
tools: {
|
|
66
|
+
slack_list_channels: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
67
|
+
slack_get_channel_history: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
68
|
+
slack_get_thread_replies: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
69
|
+
slack_get_users: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
70
|
+
slack_get_user_profile: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
71
|
+
slack_post_message: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
72
|
+
slack_reply_to_thread: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
73
|
+
slack_add_reaction: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
74
|
+
},
|
|
75
|
+
note: "preset slack: messages are untrusted (anyone in the workspace can write them) and private; posting is external egress (set public for channels with outside guests)",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "gmail",
|
|
79
|
+
// @gongrzhe/server-gmail-autoauth-mcp, the most used Gmail server. It sets no tool hints.
|
|
80
|
+
serverNames: ["gmail"],
|
|
81
|
+
// Anyone can send you email.
|
|
82
|
+
defaults: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
83
|
+
read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
84
|
+
write: { egress: "internal" },
|
|
85
|
+
tools: {
|
|
86
|
+
read_email: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
87
|
+
search_emails: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
88
|
+
list_email_labels: { integrity: "untrusted", confidentiality: "private", egress: "none" },
|
|
89
|
+
list_filters: { integrity: "trusted", confidentiality: "private", egress: "none" },
|
|
90
|
+
get_filter: { integrity: "trusted", confidentiality: "private", egress: "none" },
|
|
91
|
+
// Saves the attachment to a local folder.
|
|
92
|
+
download_attachment: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
|
|
93
|
+
send_email: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
94
|
+
draft_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
95
|
+
modify_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
96
|
+
delete_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
97
|
+
batch_modify_emails: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
98
|
+
batch_delete_emails: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
99
|
+
create_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
100
|
+
update_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
101
|
+
delete_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
102
|
+
get_or_create_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
103
|
+
delete_filter: { integrity: "trusted", confidentiality: "public", egress: "internal" },
|
|
104
|
+
// Filters can forward matching mail to any address.
|
|
105
|
+
create_filter: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
106
|
+
create_filter_from_template: { integrity: "trusted", confidentiality: "public", egress: "external" },
|
|
107
|
+
},
|
|
108
|
+
note: "preset gmail: email is untrusted (anyone can send it) and private; sending, and filters that can forward mail, are external egress",
|
|
109
|
+
},
|
|
56
110
|
];
|
|
57
111
|
|
|
58
112
|
/** The preset for an upstream: by the name the server reports, then by the upstream id. */
|
package/src/invoker.ts
CHANGED
|
@@ -55,12 +55,31 @@ export interface Blocked {
|
|
|
55
55
|
confirmation?: ConfirmationRequest;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* One interceptor invocation, with the execution policy it ran under. SEP-2624
|
|
60
|
+
* (Security Best Practice 3) asks invokers to log the resolved `mode` and
|
|
61
|
+
* `failOpen`, and whether each came from an invoker override or the
|
|
62
|
+
* interceptor's declared default.
|
|
63
|
+
*/
|
|
64
|
+
export interface InterceptorRun {
|
|
65
|
+
name: string;
|
|
66
|
+
phase: Phase;
|
|
67
|
+
mode: InterceptorMode;
|
|
68
|
+
modeFrom: "override" | "default";
|
|
69
|
+
failOpen: boolean;
|
|
70
|
+
failOpenFrom: "override" | "default";
|
|
71
|
+
/** Set when the invocation errored or timed out. */
|
|
72
|
+
failed?: true;
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
export interface PhaseOutcome {
|
|
59
76
|
payload: unknown;
|
|
60
77
|
blocked?: Blocked;
|
|
61
78
|
results: InterceptorResult[];
|
|
62
79
|
/** Interceptors that failed but were allowed to (failOpen or audit mode). */
|
|
63
80
|
warnings: string[];
|
|
81
|
+
/** Every interceptor invoked in this phase. */
|
|
82
|
+
ran: InterceptorRun[];
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
export interface InvokeOptions {
|
|
@@ -101,14 +120,14 @@ export class InterceptorInvoker {
|
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
async runRequest(opts: InvokeOptions): Promise<PhaseOutcome> {
|
|
104
|
-
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
123
|
+
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [], ran: [] };
|
|
105
124
|
if (!(await this.mutate("request", opts, out))) return out;
|
|
106
125
|
await this.validate("request", opts, out);
|
|
107
126
|
return out;
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
async runResponse(opts: InvokeOptions): Promise<PhaseOutcome> {
|
|
111
|
-
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
130
|
+
const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [], ran: [] };
|
|
112
131
|
if (!(await this.validate("response", opts, out))) return out;
|
|
113
132
|
await this.mutate("response", opts, out);
|
|
114
133
|
return out;
|
|
@@ -118,6 +137,20 @@ export class InterceptorInvoker {
|
|
|
118
137
|
return (this.modeOverride ?? i.definition.mode) === "audit";
|
|
119
138
|
}
|
|
120
139
|
|
|
140
|
+
/** Record that `i` runs in `phase`, with its resolved policy. The gateway has no failOpen overrides. */
|
|
141
|
+
private run(i: BoundInterceptor, phase: Phase, out: PhaseOutcome): InterceptorRun {
|
|
142
|
+
const r: InterceptorRun = {
|
|
143
|
+
name: i.definition.name,
|
|
144
|
+
phase,
|
|
145
|
+
mode: this.modeOverride ?? i.definition.mode ?? "active",
|
|
146
|
+
modeFrom: this.modeOverride ? "override" : "default",
|
|
147
|
+
failOpen: i.definition.failOpen === true,
|
|
148
|
+
failOpenFrom: "default",
|
|
149
|
+
};
|
|
150
|
+
out.ran.push(r);
|
|
151
|
+
return r;
|
|
152
|
+
}
|
|
153
|
+
|
|
121
154
|
private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
|
|
122
155
|
return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
|
|
123
156
|
}
|
|
@@ -150,10 +183,12 @@ export class InterceptorInvoker {
|
|
|
150
183
|
);
|
|
151
184
|
let payload = out.payload;
|
|
152
185
|
for (const i of selected) {
|
|
186
|
+
const ran = this.run(i, phase, out);
|
|
153
187
|
let r: MutationResult;
|
|
154
188
|
try {
|
|
155
189
|
r = (await this.invoke(i, phase, opts, payload)) as MutationResult;
|
|
156
190
|
} catch (err) {
|
|
191
|
+
ran.failed = true;
|
|
157
192
|
if (!this.failed(i, err, out)) return false;
|
|
158
193
|
continue;
|
|
159
194
|
}
|
|
@@ -167,11 +202,13 @@ export class InterceptorInvoker {
|
|
|
167
202
|
/** Parallel validations. Only severity "error" blocks. */
|
|
168
203
|
private async validate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
|
|
169
204
|
const selected = this.select("validation", opts.event, phase);
|
|
205
|
+
const runs = selected.map((i) => this.run(i, phase, out));
|
|
170
206
|
const settled = await Promise.allSettled(selected.map((i) => this.invoke(i, phase, opts, out.payload)));
|
|
171
207
|
let ok = true;
|
|
172
208
|
settled.forEach((s, idx) => {
|
|
173
209
|
const i = selected[idx]!;
|
|
174
210
|
if (s.status === "rejected") {
|
|
211
|
+
runs[idx]!.failed = true;
|
|
175
212
|
if (!this.failed(i, s.reason, out)) ok = false;
|
|
176
213
|
return;
|
|
177
214
|
}
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|