@adtl/gateway 0.6.0 → 0.8.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 +146 -11
- package/dist/audit.d.ts +3 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.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/config.d.ts +235 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +11 -5
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +23 -12
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/agentLinks.d.ts +18 -0
- package/dist/hooks/agentLinks.d.ts.map +1 -0
- package/dist/hooks/agentLinks.js +48 -0
- package/dist/hooks/agentLinks.js.map +1 -0
- package/dist/hooks/claudeCode.d.ts +3 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +27 -7
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +3 -1
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init/suggest.d.ts +3 -1
- package/dist/init/suggest.d.ts.map +1 -1
- package/dist/init/suggest.js +8 -0
- package/dist/init/suggest.js.map +1 -1
- package/dist/invoker.d.ts +54 -6
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +115 -19
- package/dist/invoker.js.map +1 -1
- package/dist/startup.d.ts +15 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +80 -7
- package/dist/startup.js.map +1 -1
- package/package.json +15 -4
- package/src/audit.ts +3 -1
- package/src/cedar.ts +140 -0
- package/src/config.ts +57 -0
- package/src/gateway.ts +26 -14
- package/src/hooks/agentLinks.ts +58 -0
- package/src/hooks/claudeCode.ts +31 -7
- package/src/hooks/index.ts +1 -0
- package/src/hooks/run.ts +3 -1
- package/src/index.ts +1 -0
- package/src/init/suggest.ts +10 -1
- package/src/invoker.ts +146 -21
- package/src/startup.ts +75 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adtl/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Experimental: MCP gateway that labels every tool result and blocks risky tool calls using ADTL trust labels",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -47,18 +47,29 @@
|
|
|
47
47
|
"url": "https://github.com/probita-ai/adtl/issues"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adtl/interceptors": "0.
|
|
51
|
-
"@adtl/labels": "0.
|
|
52
|
-
"@adtl/ledger": "0.
|
|
50
|
+
"@adtl/interceptors": "0.8.0",
|
|
51
|
+
"@adtl/labels": "0.8.0",
|
|
52
|
+
"@adtl/ledger": "0.8.0",
|
|
53
53
|
"@modelcontextprotocol/sdk": "1.31.0",
|
|
54
54
|
"nodemailer": "10.0.12",
|
|
55
55
|
"yaml": "2.9.1",
|
|
56
56
|
"zod": "3.25.76"
|
|
57
57
|
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@cedar-policy/cedar-wasm": "^4.13.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@cedar-policy/cedar-wasm": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
58
66
|
"engines": {
|
|
59
67
|
"node": ">=22.13"
|
|
60
68
|
},
|
|
61
69
|
"publishConfig": {
|
|
62
70
|
"access": "public"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@cedar-policy/cedar-wasm": "4.13.0"
|
|
63
74
|
}
|
|
64
75
|
}
|
package/src/audit.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface AuditEntry {
|
|
|
33
33
|
outcome: "forwarded" | "blocked" | "asked" | "upstream_error";
|
|
34
34
|
/** Present when the entry was written by the Claude Code hook rather than the gateway. */
|
|
35
35
|
via?: "claude-code-hook";
|
|
36
|
-
/** Present when a
|
|
36
|
+
/** Present when a sub-agent made the call: its id and type (e.g. "Explore"), from Claude Code or `ADTL_AGENT_ID`. */
|
|
37
37
|
agent?: { id: string; type?: string };
|
|
38
38
|
/** The policy's decision. In audit mode a call can be forwarded with decision "ask" or "block". */
|
|
39
39
|
decision?: string;
|
|
@@ -51,6 +51,8 @@ export interface AuditEntry {
|
|
|
51
51
|
ledger?: unknown;
|
|
52
52
|
/** Present when the result carried record label stamps: how many, and which sessions wrote them. */
|
|
53
53
|
records?: unknown;
|
|
54
|
+
/** Present when the ledger knew record ids in the result (tool rule `recordIds`): how many, and which sessions wrote them. */
|
|
55
|
+
recordIds?: unknown;
|
|
54
56
|
sessionLabel?: unknown;
|
|
55
57
|
/** Every interceptor invocation for this call, with its resolved mode and failOpen and where each came from (SEP-2624). */
|
|
56
58
|
interceptors?: InterceptorRun[];
|
package/src/cedar.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribute-based authorization with Cedar (`gateway.cedar`):
|
|
3
|
+
*
|
|
4
|
+
* cedar:
|
|
5
|
+
* policies: ./adtl.cedar # Cedar policies, relative to adtl.yaml
|
|
6
|
+
* entities: ./entities.json # optional: Cedar entities (groups, attributes)
|
|
7
|
+
* timeZone: Europe/Berlin # optional: for context.time (default: the gateway's)
|
|
8
|
+
*
|
|
9
|
+
* Every call becomes a Cedar request:
|
|
10
|
+
*
|
|
11
|
+
* principal User::"alice", Service::"ci", or Anonymous::"anonymous" without an id
|
|
12
|
+
* action Action::"tools/call", Action::"resources/read" or Action::"prompts/get"
|
|
13
|
+
* resource Tool::"github.add_issue_comment", Prompt::"github.triage" or Resource::"<uri>"
|
|
14
|
+
* context { server, principalId, listing, time: { hour, minute, weekday }, arguments }
|
|
15
|
+
*
|
|
16
|
+
* Cedar denies unless a policy permits, and a `forbid` wins over any permit.
|
|
17
|
+
* Tool lists are filtered with `listing: true` and no `arguments`, so a policy
|
|
18
|
+
* on arguments should start with `context.listing ||`.
|
|
19
|
+
*
|
|
20
|
+
* Needs the optional dependency @cedar-policy/cedar-wasm, loaded only when
|
|
21
|
+
* `gateway.cedar` is set.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import type { AuthorizationDecision, AuthorizationRequest, Authorizer } from "./authorizer.js";
|
|
25
|
+
import type { CedarConfig } from "./config.js";
|
|
26
|
+
|
|
27
|
+
type CedarValue = boolean | number | string | CedarValue[] | { [key: string]: CedarValue };
|
|
28
|
+
|
|
29
|
+
/** The parts of @cedar-policy/cedar-wasm used here. */
|
|
30
|
+
interface CedarWasm {
|
|
31
|
+
isAuthorized(call: unknown): CedarAnswer;
|
|
32
|
+
checkParsePolicySet(policies: unknown): { type: "success" } | { type: "failure"; errors: { message: string }[] };
|
|
33
|
+
checkParseEntities(call: unknown): { type: "success" } | { type: "failure"; errors: { message: string }[] };
|
|
34
|
+
}
|
|
35
|
+
type CedarAnswer =
|
|
36
|
+
| { type: "failure"; errors: { message: string }[] }
|
|
37
|
+
| { type: "success"; response: { decision: "allow" | "deny"; diagnostics: { reason: string[]; errors: { policyId: string; error: { message: string } }[] } } };
|
|
38
|
+
|
|
39
|
+
export class CedarAuthorizer implements Authorizer {
|
|
40
|
+
readonly name = "cedar";
|
|
41
|
+
|
|
42
|
+
private constructor(
|
|
43
|
+
private readonly cedar: CedarWasm,
|
|
44
|
+
private readonly policies: string,
|
|
45
|
+
private readonly entities: unknown[],
|
|
46
|
+
private readonly timeZone: string | undefined,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
/** Load the Cedar engine and the policy files. Throws on a missing package or files that don't parse. */
|
|
50
|
+
static async load(cfg: CedarConfig, resolvePath: (p: string) => string): Promise<CedarAuthorizer> {
|
|
51
|
+
let cedar: CedarWasm;
|
|
52
|
+
try {
|
|
53
|
+
cedar = (await import("@cedar-policy/cedar-wasm/nodejs")) as unknown as CedarWasm;
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error("gateway.cedar needs the Cedar engine: npm install @cedar-policy/cedar-wasm");
|
|
56
|
+
}
|
|
57
|
+
const policies = readFileSync(resolvePath(cfg.policies), "utf8");
|
|
58
|
+
const parsed = cedar.checkParsePolicySet({ staticPolicies: policies });
|
|
59
|
+
if (parsed.type === "failure") throw new Error(`gateway.cedar.policies: ${parsed.errors.map((e) => e.message).join("; ")}`);
|
|
60
|
+
|
|
61
|
+
let entities: unknown[] = [];
|
|
62
|
+
if (cfg.entities) {
|
|
63
|
+
const json: unknown = JSON.parse(readFileSync(resolvePath(cfg.entities), "utf8"));
|
|
64
|
+
if (!Array.isArray(json)) throw new Error("gateway.cedar.entities: expected a JSON array of Cedar entities");
|
|
65
|
+
const ok = cedar.checkParseEntities({ entities: json });
|
|
66
|
+
if (ok.type === "failure") throw new Error(`gateway.cedar.entities: ${ok.errors.map((e) => e.message).join("; ")}`);
|
|
67
|
+
entities = json;
|
|
68
|
+
}
|
|
69
|
+
return new CedarAuthorizer(cedar, policies, entities, cfg.timeZone);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
check(req: AuthorizationRequest): AuthorizationDecision {
|
|
73
|
+
const { principal } = req;
|
|
74
|
+
const [type, id] =
|
|
75
|
+
req.event === "tools/call"
|
|
76
|
+
? (["Tool", `${req.server}.${req.tool}`] as const)
|
|
77
|
+
: req.event === "prompts/get"
|
|
78
|
+
? (["Prompt", `${req.server}.${req.prompt}`] as const)
|
|
79
|
+
: (["Resource", req.resource ?? ""] as const);
|
|
80
|
+
const context: Record<string, CedarValue> = {
|
|
81
|
+
server: req.server,
|
|
82
|
+
listing: req.arguments === undefined,
|
|
83
|
+
time: clock(new Date(), this.timeZone),
|
|
84
|
+
...(principal.id !== undefined ? { principalId: principal.id } : {}),
|
|
85
|
+
...(req.arguments !== undefined ? { arguments: toCedar(req.arguments) ?? {} } : {}),
|
|
86
|
+
};
|
|
87
|
+
const answer = this.cedar.isAuthorized({
|
|
88
|
+
principal:
|
|
89
|
+
principal.id === undefined
|
|
90
|
+
? { type: "Anonymous", id: "anonymous" }
|
|
91
|
+
: { type: principal.type === "service" ? "Service" : "User", id: principal.id },
|
|
92
|
+
action: { type: "Action", id: req.event },
|
|
93
|
+
resource: { type, id },
|
|
94
|
+
context,
|
|
95
|
+
policies: { staticPolicies: this.policies },
|
|
96
|
+
entities: this.entities,
|
|
97
|
+
});
|
|
98
|
+
if (answer.type === "failure") throw new Error(answer.errors.map((e) => e.message).join("; "));
|
|
99
|
+
const { decision, diagnostics } = answer.response;
|
|
100
|
+
if (decision === "allow") return { allow: true };
|
|
101
|
+
const who = principal.id ?? "an anonymous caller";
|
|
102
|
+
const what = `the ${type.toLowerCase()} ${id}`;
|
|
103
|
+
return {
|
|
104
|
+
allow: false,
|
|
105
|
+
reason: diagnostics.reason.length
|
|
106
|
+
? `Cedar policy ${diagnostics.reason.join(", ")} forbids ${who} to use ${what}`
|
|
107
|
+
: `No Cedar policy permits ${who} to use ${what}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Hour, minute and weekday ("Mon") in `timeZone`, for working-hours rules. */
|
|
113
|
+
export function clock(now: Date, timeZone: string | undefined): Record<string, CedarValue> {
|
|
114
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", hour: "numeric", minute: "numeric", weekday: "short" }).formatToParts(now);
|
|
115
|
+
const part = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
|
116
|
+
return { hour: Number(part("hour")), minute: Number(part("minute")), weekday: part("weekday") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Tool arguments as Cedar context values. Cedar has no null or fractions, so
|
|
121
|
+
* nulls are left out and fractions become strings. An object with `__entity`
|
|
122
|
+
* or `__extn` would be read as an entity or extension value, so the agent
|
|
123
|
+
* could name any entity; it is passed as a JSON string instead.
|
|
124
|
+
*/
|
|
125
|
+
export function toCedar(v: unknown): CedarValue | undefined {
|
|
126
|
+
if (v === null || v === undefined) return undefined;
|
|
127
|
+
if (typeof v === "boolean" || typeof v === "string") return v;
|
|
128
|
+
if (typeof v === "number") return Number.isSafeInteger(v) ? v : String(v);
|
|
129
|
+
if (Array.isArray(v)) return v.map(toCedar).filter((x): x is CedarValue => x !== undefined);
|
|
130
|
+
if (typeof v === "object") {
|
|
131
|
+
if ("__entity" in v || "__extn" in v) return JSON.stringify(v);
|
|
132
|
+
const out: Record<string, CedarValue> = {};
|
|
133
|
+
for (const [k, x] of Object.entries(v)) {
|
|
134
|
+
const c = toCedar(x);
|
|
135
|
+
if (c !== undefined) out[k] = c;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
return String(v);
|
|
140
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* docs:
|
|
13
13
|
* url: https://example.com/mcp
|
|
14
14
|
* headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
|
|
15
|
+
* interceptorServers: # optional: more interceptor servers, run beside ADTL's
|
|
16
|
+
* pii: { url: https://pii.example.com/mcp }
|
|
17
|
+
* interceptorOverrides: # optional: per-interceptor execution policy (SEP-2624)
|
|
18
|
+
* adtl.label: { timeoutMs: 10000 }
|
|
15
19
|
* principal: { id: "${USER}" } # optional: who runs this gateway (default: anonymous)
|
|
16
20
|
* roles: # optional: who may use which tools (deny by default)
|
|
17
21
|
* maintainers: { members: [alice], tools: ["github.*"] }
|
|
18
22
|
* readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
|
|
23
|
+
* cedar: { policies: ./adtl.cedar } # optional: attribute-based rules (see cedar.ts)
|
|
19
24
|
* approvals: # optional: make "ask" really ask
|
|
20
25
|
* timeoutSeconds: 50
|
|
21
26
|
* ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
|
|
@@ -138,6 +143,47 @@ export const RoleSchema = z
|
|
|
138
143
|
});
|
|
139
144
|
export type RoleConfig = z.infer<typeof RoleSchema>;
|
|
140
145
|
|
|
146
|
+
export const CedarConfigSchema = z
|
|
147
|
+
.object({
|
|
148
|
+
/** Cedar policy file. Relative paths are resolved against the config file's directory. */
|
|
149
|
+
policies: z.string().min(1),
|
|
150
|
+
/** Optional Cedar entities JSON file: groups (`parents`) and attributes. */
|
|
151
|
+
entities: z.string().min(1).optional(),
|
|
152
|
+
/** IANA time zone for `context.time`, e.g. "Europe/Berlin". Default: the gateway's. */
|
|
153
|
+
timeZone: z
|
|
154
|
+
.string()
|
|
155
|
+
.min(1)
|
|
156
|
+
.refine((tz) => {
|
|
157
|
+
try {
|
|
158
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}, "Unknown time zone")
|
|
164
|
+
.optional(),
|
|
165
|
+
})
|
|
166
|
+
.strict();
|
|
167
|
+
export type CedarConfig = z.infer<typeof CedarConfigSchema>;
|
|
168
|
+
|
|
169
|
+
const PhaseSchema = z.enum(["request", "response"]);
|
|
170
|
+
|
|
171
|
+
/** SEP-2624 InterceptorOverrides, set by the gateway owner for one interceptor. */
|
|
172
|
+
export const InterceptorOverridesSchema = z
|
|
173
|
+
.object({
|
|
174
|
+
failOpen: z.boolean().optional(),
|
|
175
|
+
priorityHint: z
|
|
176
|
+
.union([z.number().int(), z.object({ request: z.number().int().optional(), response: z.number().int().optional() }).strict()])
|
|
177
|
+
.optional(),
|
|
178
|
+
mode: z.enum(["active", "audit"]).optional(),
|
|
179
|
+
timeoutMs: z.number().int().positive().max(600_000).optional(),
|
|
180
|
+
hooks: z
|
|
181
|
+
.array(z.object({ events: z.array(z.string().min(1)).min(1), phase: PhaseSchema }).strict())
|
|
182
|
+
.min(1, "hooks: list at least one hook, or leave it out to keep the declared hooks")
|
|
183
|
+
.optional(),
|
|
184
|
+
})
|
|
185
|
+
.strict();
|
|
186
|
+
|
|
141
187
|
export const GatewayConfigSchema = z
|
|
142
188
|
.object({
|
|
143
189
|
upstreams: z
|
|
@@ -148,6 +194,13 @@ export const GatewayConfigSchema = z
|
|
|
148
194
|
}),
|
|
149
195
|
/** How to start the interceptor server. Default: the bundled adtl-interceptors with this same config file. */
|
|
150
196
|
interceptors: StdioUpstreamSchema.optional(),
|
|
197
|
+
/** More interceptor servers (stdio or Streamable HTTP) whose interceptors run beside ADTL's. */
|
|
198
|
+
interceptorServers: z
|
|
199
|
+
.record(UpstreamSchema)
|
|
200
|
+
.refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
|
|
201
|
+
message: "Interceptor server ids may only contain letters, digits and '-'",
|
|
202
|
+
})
|
|
203
|
+
.default({}),
|
|
151
204
|
/** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
|
|
152
205
|
audit: z.string().min(1).optional(),
|
|
153
206
|
/**
|
|
@@ -159,6 +212,8 @@ export const GatewayConfigSchema = z
|
|
|
159
212
|
approvals: ApprovalsConfigSchema.optional(),
|
|
160
213
|
/** Timeout for each interceptor invocation, in milliseconds. */
|
|
161
214
|
interceptorTimeoutMs: z.number().int().positive().default(5000),
|
|
215
|
+
/** Per-interceptor overrides of failOpen, priorityHint, mode, timeoutMs and hooks, by interceptor name. */
|
|
216
|
+
interceptorOverrides: z.record(InterceptorOverridesSchema).default({}),
|
|
162
217
|
/** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
|
|
163
218
|
principal: PrincipalConfigSchema.optional(),
|
|
164
219
|
/** Role-based authorization: when present, a call no role grants is denied. */
|
|
@@ -166,6 +221,8 @@ export const GatewayConfigSchema = z
|
|
|
166
221
|
.record(RoleSchema)
|
|
167
222
|
.refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
|
|
168
223
|
.optional(),
|
|
224
|
+
/** Cedar authorization. With roles as well, both must allow. */
|
|
225
|
+
cedar: CedarConfigSchema.optional(),
|
|
169
226
|
})
|
|
170
227
|
.strict();
|
|
171
228
|
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
package/src/gateway.ts
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
* unanswered or undeliverable calls are stopped. Without an approval channel,
|
|
22
22
|
* "ask" blocks.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* resource or prompt at all;
|
|
26
|
-
* The lists the agent sees leave out what
|
|
24
|
+
* Optional Authorizers decide first whether the caller may use the tool,
|
|
25
|
+
* resource or prompt at all; any denial stops the call before the policy runs.
|
|
26
|
+
* The lists the agent sees leave out what an authorizer would deny, so the
|
|
27
27
|
* agent doesn't try it; hiding is a convenience, and every call is still checked.
|
|
28
28
|
*
|
|
29
29
|
* In audit mode (the invoker runs every interceptor with `mode: "audit"`)
|
|
@@ -62,7 +62,7 @@ import { authorize, type AuthorizationRequest, type Authorizer, type Denial, typ
|
|
|
62
62
|
import { TOOL_SEPARATOR } from "./config.js";
|
|
63
63
|
import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
|
|
64
64
|
|
|
65
|
-
export const GATEWAY_VERSION = "0.
|
|
65
|
+
export const GATEWAY_VERSION = "0.8.0";
|
|
66
66
|
|
|
67
67
|
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
68
68
|
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
@@ -107,14 +107,16 @@ export interface AdtlGatewayOptions {
|
|
|
107
107
|
logger?: GatewayLogger;
|
|
108
108
|
/** Asks humans about "ask" decisions and sends notices. */
|
|
109
109
|
approvals?: ApprovalBroker;
|
|
110
|
-
/** Decides whether the caller may use a tool, resource or prompt at all, before the policy. */
|
|
111
|
-
authorizer?: Authorizer;
|
|
110
|
+
/** Decides whether the caller may use a tool, resource or prompt at all, before the policy. Several must all allow. */
|
|
111
|
+
authorizer?: Authorizer | Authorizer[];
|
|
112
112
|
/** The caller, passed to the authorizer and the interceptors. Default: anonymous. */
|
|
113
113
|
principal?: Principal;
|
|
114
114
|
/** Members of each role that policy rules name as `approvers:`. */
|
|
115
115
|
approverRoles?: Map<string, string[]>;
|
|
116
116
|
/** Also ask for approval in the MCP client, when it supports elicitation. */
|
|
117
117
|
elicitApprovals?: boolean;
|
|
118
|
+
/** The (sub-)agent this gateway serves, recorded on every audit entry. */
|
|
119
|
+
agent?: { id: string; type?: string };
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
/**
|
|
@@ -172,8 +174,9 @@ export class AdtlGateway {
|
|
|
172
174
|
private readonly audit: AuditSink | undefined;
|
|
173
175
|
private readonly log: GatewayLogger;
|
|
174
176
|
private readonly approvals: ApprovalBroker | undefined;
|
|
175
|
-
private readonly
|
|
177
|
+
private readonly authorizers: Authorizer[];
|
|
176
178
|
private readonly principal: Principal;
|
|
179
|
+
private readonly agent: { agent: { id: string; type?: string } } | Record<string, never>;
|
|
177
180
|
private readonly approverRoles: Map<string, string[]>;
|
|
178
181
|
private readonly elicitApprovals: boolean;
|
|
179
182
|
/** The server the agent is connected to, for elicitation. */
|
|
@@ -191,15 +194,20 @@ export class AdtlGateway {
|
|
|
191
194
|
this.audit = opts.audit;
|
|
192
195
|
this.log = opts.logger ?? stderrLogger;
|
|
193
196
|
this.approvals = opts.approvals;
|
|
194
|
-
this.
|
|
197
|
+
this.authorizers = opts.authorizer === undefined ? [] : [opts.authorizer].flat();
|
|
195
198
|
this.principal = opts.principal ?? { type: "anonymous" };
|
|
199
|
+
this.agent = opts.agent ? { agent: opts.agent } : {};
|
|
196
200
|
this.approverRoles = opts.approverRoles ?? new Map();
|
|
197
201
|
this.elicitApprovals = opts.elicitApprovals === true;
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
/** Ask
|
|
204
|
+
/** Ask each authorizer in turn. Returns the first denial; in audit mode the caller logs it and carries on. */
|
|
201
205
|
private async authorize(request: Omit<AuthorizationRequest, "principal">): Promise<Denial | undefined> {
|
|
202
|
-
|
|
206
|
+
for (const a of this.authorizers) {
|
|
207
|
+
const denied = await authorize(a, { principal: this.principal, ...request });
|
|
208
|
+
if (denied) return denied;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
/** The downstream MCP server the agent connects to. */
|
|
@@ -299,7 +307,7 @@ export class AdtlGateway {
|
|
|
299
307
|
* call: routes are kept, and a call to a hidden item is still authorized.
|
|
300
308
|
*/
|
|
301
309
|
private async hideDenied<T>(items: T[], request: (item: T) => Omit<AuthorizationRequest, "principal"> | undefined): Promise<T[]> {
|
|
302
|
-
if (!this.
|
|
310
|
+
if (!this.authorizers.length || this.invoker.modeOverride === "audit") return items;
|
|
303
311
|
const shown = await Promise.all(
|
|
304
312
|
items.map(async (item) => {
|
|
305
313
|
const r = request(item);
|
|
@@ -446,6 +454,7 @@ export class AdtlGateway {
|
|
|
446
454
|
this.audit?.write({
|
|
447
455
|
ts: context.timestamp!,
|
|
448
456
|
sessionId: this.sessionId,
|
|
457
|
+
...this.agent,
|
|
449
458
|
event,
|
|
450
459
|
server: upstream,
|
|
451
460
|
...target,
|
|
@@ -497,7 +506,7 @@ export class AdtlGateway {
|
|
|
497
506
|
}
|
|
498
507
|
const label = infoOf(res, "adtl.label");
|
|
499
508
|
finish({ outcome: "forwarded", ...audit, label: label.label, ...(label.ledger ? { ledger: label.ledger } : {}),
|
|
500
|
-
...(label.records ? { records: label.records } : {}), sessionLabel: label.sessionLabel, warnings });
|
|
509
|
+
...(label.records ? { records: label.records } : {}), ...(label.recordIds ? { recordIds: label.recordIds } : {}), sessionLabel: label.sessionLabel, warnings });
|
|
501
510
|
return (res.payload as { result: R }).result;
|
|
502
511
|
}
|
|
503
512
|
|
|
@@ -521,7 +530,7 @@ export class AdtlGateway {
|
|
|
521
530
|
server: route.upstream,
|
|
522
531
|
...(route.annotations ? { toolAnnotations: route.annotations } : {}),
|
|
523
532
|
};
|
|
524
|
-
const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
533
|
+
const base = { sessionId: this.sessionId, ...this.agent, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
525
534
|
let denied: Denial | undefined;
|
|
526
535
|
const ran: InterceptorRun[] = [];
|
|
527
536
|
const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
|
|
@@ -626,7 +635,9 @@ export class AdtlGateway {
|
|
|
626
635
|
// 2. Forward to the upstream server.
|
|
627
636
|
let result: CallToolResult;
|
|
628
637
|
try {
|
|
629
|
-
|
|
638
|
+
// A mutator may add request _meta (e.g. fides-gateway labels); pass it on.
|
|
639
|
+
const meta = (forwarded as { _meta?: Record<string, unknown> })._meta;
|
|
640
|
+
result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments, ...(meta ? { _meta: meta } : {}) })) as CallToolResult;
|
|
630
641
|
} catch (err) {
|
|
631
642
|
const reason = (err as Error).message;
|
|
632
643
|
finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
|
|
@@ -659,6 +670,7 @@ export class AdtlGateway {
|
|
|
659
670
|
label: label.label,
|
|
660
671
|
...(label.ledger ? { ledger: label.ledger } : {}),
|
|
661
672
|
...(label.records ? { records: label.records } : {}),
|
|
673
|
+
...(label.recordIds ? { recordIds: label.recordIds } : {}),
|
|
662
674
|
sessionLabel: label.sessionLabel,
|
|
663
675
|
warnings: [...req.warnings, ...res.warnings],
|
|
664
676
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which session a Claude Code sub-agent belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's SubagentStart hook reports the parent's `session_id` with the
|
|
5
|
+
* new sub-agent's `agent_id`. Recording that link lets the sub-agent's later
|
|
6
|
+
* hook calls use the parent's label even if they ever report a session id of
|
|
7
|
+
* their own: the sub-agent starts from what its parent has seen, and what it
|
|
8
|
+
* reads taints the parent. Links live beside the session files, which the
|
|
9
|
+
* hooks already protect from the agent.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export interface AgentLinks {
|
|
16
|
+
/** The session `agentId` was started in, if known. */
|
|
17
|
+
parentOf(agentId: string): string | undefined;
|
|
18
|
+
link(agentId: string, sessionId: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
22
|
+
|
|
23
|
+
export class FileAgentLinks implements AgentLinks {
|
|
24
|
+
private readonly dir: string;
|
|
25
|
+
|
|
26
|
+
constructor(sessionsDir: string) {
|
|
27
|
+
this.dir = join(sessionsDir, "agents");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private pathOf(agentId: string): string {
|
|
31
|
+
const name = SAFE_ID.test(agentId) ? agentId : `h-${createHash("sha256").update(agentId).digest("hex")}`;
|
|
32
|
+
return join(this.dir, `${name}.parent`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
parentOf(agentId: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(this.pathOf(agentId), "utf8").trim() || undefined;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
link(agentId: string, sessionId: string): void {
|
|
45
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
writeFileSync(this.pathOf(agentId), sessionId, { encoding: "utf8", mode: 0o600 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class InMemoryAgentLinks implements AgentLinks {
|
|
51
|
+
private readonly links = new Map<string, string>();
|
|
52
|
+
parentOf(agentId: string): string | undefined {
|
|
53
|
+
return this.links.get(agentId);
|
|
54
|
+
}
|
|
55
|
+
link(agentId: string, sessionId: string): void {
|
|
56
|
+
this.links.set(agentId, sessionId);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* allow → no answer, so Claude Code's own permissions apply
|
|
9
9
|
* PostToolUse labels the tool's output and adds it to the session label
|
|
10
10
|
* PostToolUseFailure same: a failed tool's error output still reaches the model
|
|
11
|
+
* SubagentStart links the new sub-agent to its parent's session, so its
|
|
12
|
+
* calls start from the parent's label and taint it
|
|
11
13
|
*
|
|
12
14
|
* Built-in tools are labeled as the server `claude-code` (see preset.ts); MCP
|
|
13
15
|
* tools `mcp__<server>__<tool>` as `<server>`. Tools of the gateway's own MCP
|
|
@@ -33,11 +35,13 @@ import {
|
|
|
33
35
|
type AdtlConfig,
|
|
34
36
|
type Decision,
|
|
35
37
|
type PolicyDecision,
|
|
38
|
+
type Label,
|
|
36
39
|
type SessionStore,
|
|
37
40
|
join,
|
|
38
41
|
} from "@adtl/labels";
|
|
39
42
|
import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
|
|
40
43
|
import type { AuditEntry, AuditSink } from "../audit.js";
|
|
44
|
+
import type { AgentLinks } from "./agentLinks.js";
|
|
41
45
|
import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
|
|
42
46
|
|
|
43
47
|
export interface HookInput {
|
|
@@ -64,6 +68,8 @@ export interface HookOptions {
|
|
|
64
68
|
audit?: AuditSink;
|
|
65
69
|
/** Label ledger shared with the gateway. */
|
|
66
70
|
ledger?: Ledger;
|
|
71
|
+
/** Sub-agent → parent session links, from SubagentStart. */
|
|
72
|
+
agents?: AgentLinks;
|
|
67
73
|
/** Files the agent must not change: the ADTL config, the session directory and the ledger. */
|
|
68
74
|
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
|
|
69
75
|
}
|
|
@@ -172,19 +178,37 @@ export function addedLines(output: unknown): string[] {
|
|
|
172
178
|
return out;
|
|
173
179
|
}
|
|
174
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The session whose label a hook call reads and adds to: a linked sub-agent's
|
|
183
|
+
* parent session, else the call's own. When the two differ, the sub-agent's own
|
|
184
|
+
* label is read too, so nothing it saw is lost.
|
|
185
|
+
*/
|
|
186
|
+
function sessionFor(input: HookInput & { session_id: string }, opts: HookOptions): { id: string; get(): Label } {
|
|
187
|
+
const parent = typeof input.agent_id === "string" ? opts.agents?.parentOf(input.agent_id) : undefined;
|
|
188
|
+
if (!parent || parent === input.session_id) return { id: input.session_id, get: () => opts.sessions.get(input.session_id) };
|
|
189
|
+
return { id: parent, get: () => join(opts.sessions.get(parent), opts.sessions.get(input.session_id)) };
|
|
190
|
+
}
|
|
191
|
+
|
|
175
192
|
export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
176
193
|
const event = input.hook_event_name;
|
|
194
|
+
if (event === "SubagentStart") {
|
|
195
|
+
if (typeof input.agent_id === "string" && typeof input.session_id === "string" && input.session_id !== "") {
|
|
196
|
+
opts.agents?.link(input.agent_id, input.session_id);
|
|
197
|
+
}
|
|
198
|
+
return NOTHING;
|
|
199
|
+
}
|
|
177
200
|
if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
|
|
178
201
|
if (typeof input.tool_name !== "string" || typeof input.session_id !== "string" || input.session_id === "") {
|
|
179
202
|
return failure(event, "hook input has no tool_name or session_id");
|
|
180
203
|
}
|
|
204
|
+
const session = sessionFor(input as HookInput & { session_id: string }, opts);
|
|
181
205
|
const route = routeTool(input.tool_name, opts.gatewayServer);
|
|
182
206
|
if (!route) return NOTHING;
|
|
183
207
|
const config = withClaudeCodePreset(opts.config);
|
|
184
208
|
const ts = new Date().toISOString();
|
|
185
209
|
const agent =
|
|
186
210
|
typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
|
|
187
|
-
const base = { ts, sessionId:
|
|
211
|
+
const base = { ts, sessionId: session.id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
|
|
188
212
|
|
|
189
213
|
if (event !== "PreToolUse") {
|
|
190
214
|
const output = input.tool_response ?? input.error ?? null;
|
|
@@ -197,20 +221,20 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
197
221
|
ledger = { chunks: match.chunks, writers: match.writers };
|
|
198
222
|
}
|
|
199
223
|
// What the tool was given came from the session as it was before this output.
|
|
200
|
-
const before =
|
|
224
|
+
const before = session.get();
|
|
201
225
|
const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
|
|
202
|
-
opts.ledger.record(written, before,
|
|
226
|
+
opts.ledger.record(written, before, session.id, route);
|
|
203
227
|
}
|
|
204
|
-
const sessionLabel = opts.sessions.observe(
|
|
228
|
+
const sessionLabel = join(session.get(), opts.sessions.observe(session.id, label));
|
|
205
229
|
opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
|
|
206
230
|
return NOTHING;
|
|
207
231
|
}
|
|
208
232
|
|
|
209
233
|
const hash = callHash(route.server, route.tool, input.tool_input ?? {});
|
|
210
|
-
const
|
|
234
|
+
const current = session.get();
|
|
211
235
|
const guard = protectedTarget(input, route, opts.protect);
|
|
212
236
|
const policy: PolicyDecision = evaluatePolicy(config.policy, {
|
|
213
|
-
session,
|
|
237
|
+
session: current,
|
|
214
238
|
...route,
|
|
215
239
|
egress: toolEgress(config, route.server, route.tool),
|
|
216
240
|
});
|
|
@@ -229,7 +253,7 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
229
253
|
reason: d.reason,
|
|
230
254
|
matched: d.matched,
|
|
231
255
|
callHash: hash,
|
|
232
|
-
sessionLabel:
|
|
256
|
+
sessionLabel: current,
|
|
233
257
|
durationMs: 0,
|
|
234
258
|
};
|
|
235
259
|
opts.audit?.write(entry);
|
package/src/hooks/index.ts
CHANGED
package/src/hooks/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `adtl-gateway hook --config adtl.yaml`: the command Claude Code runs for
|
|
3
|
-
* PreToolUse, PostToolUse and
|
|
3
|
+
* PreToolUse, PostToolUse, PostToolUseFailure and SubagentStart. Reads the hook event from
|
|
4
4
|
* stdin and answers on stdout (JSON) or stderr (with exit code 2).
|
|
5
5
|
*
|
|
6
6
|
* The config needs no `gateway:` section; if there is one, its `audit` log and
|
|
@@ -13,6 +13,7 @@ import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
|
13
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
15
|
import { JsonlAuditLog } from "../audit.js";
|
|
16
|
+
import { FileAgentLinks } from "./agentLinks.js";
|
|
16
17
|
import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
|
|
17
18
|
|
|
18
19
|
export interface RunHookOptions {
|
|
@@ -46,6 +47,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
46
47
|
return handleHook(input, {
|
|
47
48
|
config,
|
|
48
49
|
sessions: new FileSessionStore(sessionsDir),
|
|
50
|
+
agents: new FileAgentLinks(sessionsDir),
|
|
49
51
|
...(ledger ? { ledger } : {}),
|
|
50
52
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
51
53
|
mode: gw.mode === "audit" ? "audit" : "active",
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./hooks/index.js";
|
|
|
12
12
|
export * from "./ledgerCli.js";
|
|
13
13
|
export * from "./auditCli.js";
|
|
14
14
|
export * from "./roles.js";
|
|
15
|
+
export * from "./cedar.js";
|
|
15
16
|
export * from "./elicitation.js";
|
|
16
17
|
export * from "./receipt.js";
|
|
17
18
|
export * from "./receiptCli.js";
|
package/src/init/suggest.ts
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* Without a preset, rules come from the MCP tool annotations. Those are the
|
|
5
5
|
* server's own claims, so they only decide egress; integrity is always
|
|
6
6
|
* untrusted and confidentiality private until the owner says otherwise.
|
|
7
|
+
* The draft action-metadata extension's `inputMetadata.destination`, when a
|
|
8
|
+
* tool declares one, decides egress ahead of the legacy hints.
|
|
7
9
|
* Missing hints take the MCP spec defaults (readOnlyHint false, openWorldHint true).
|
|
8
10
|
*
|
|
9
11
|
* Merging never changes an entry that is already in the file: the owner's
|
|
10
12
|
* choices win, and `init` can be re-run to pick up new tools.
|
|
11
13
|
*/
|
|
12
14
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
-
import type
|
|
15
|
+
import { destinationEgress, readActionMetadata, type ToolRule } from "@adtl/labels";
|
|
14
16
|
import { isMap, isScalar, type Document, type YAMLMap } from "yaml";
|
|
15
17
|
import { choosePreset, PRESETS, type Preset } from "./presets.js";
|
|
16
18
|
|
|
@@ -40,6 +42,13 @@ const UNKNOWN_DEFAULTS: ToolRule = { integrity: "untrusted", confidentiality: "p
|
|
|
40
42
|
|
|
41
43
|
/** A rule from the tool's MCP annotations alone. */
|
|
42
44
|
export function ruleFromHints(annotations: Tool["annotations"]): Omit<ToolSuggestion, "name"> {
|
|
45
|
+
const meta = readActionMetadata(annotations);
|
|
46
|
+
const declared = destinationEgress(meta.destination);
|
|
47
|
+
if (declared) {
|
|
48
|
+
const extra = [meta.outcome, meta.requiresReview ? "requires review" : undefined].filter(Boolean).join(", ");
|
|
49
|
+
const why = `action-metadata: destination ${meta.destination}${extra ? `, ${extra}` : ""} (review)`;
|
|
50
|
+
return { rule: { integrity: "untrusted", confidentiality: "private", egress: declared }, why };
|
|
51
|
+
}
|
|
43
52
|
const readOnly = annotations?.readOnlyHint === true;
|
|
44
53
|
const openWorld = annotations?.openWorldHint !== false;
|
|
45
54
|
const egress = readOnly ? (openWorld ? "external" : "none") : openWorld ? "public" : "internal";
|