@adtl/gateway 0.3.1 → 0.4.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 +45 -1
- package/dist/audit.d.ts +2 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +54 -15
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +49 -44
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +1 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +3 -2
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +7 -1
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +43 -3
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +7 -2
- 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/fromMcpJson.d.ts +41 -0
- package/dist/init/fromMcpJson.d.ts.map +1 -0
- package/dist/init/fromMcpJson.js +141 -0
- package/dist/init/fromMcpJson.js.map +1 -0
- package/dist/init/index.d.ts +2 -0
- package/dist/init/index.d.ts.map +1 -1
- package/dist/init/index.js +2 -0
- package/dist/init/index.js.map +1 -1
- package/dist/init/run.d.ts +13 -0
- package/dist/init/run.d.ts.map +1 -1
- package/dist/init/run.js +83 -11
- package/dist/init/run.js.map +1 -1
- package/dist/ledgerCli.d.ts +7 -0
- package/dist/ledgerCli.d.ts.map +1 -0
- package/dist/ledgerCli.js +86 -0
- package/dist/ledgerCli.js.map +1 -0
- package/dist/startup.d.ts +2 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +9 -2
- package/dist/startup.js.map +1 -1
- package/package.json +6 -5
- package/src/audit.ts +2 -0
- package/src/cli.ts +46 -15
- package/src/config.ts +9 -3
- package/src/gateway.ts +3 -2
- package/src/hooks/claudeCode.ts +47 -4
- package/src/hooks/run.ts +7 -2
- package/src/index.ts +1 -0
- package/src/init/fromMcpJson.ts +165 -0
- package/src/init/index.ts +2 -0
- package/src/init/run.ts +91 -12
- package/src/ledgerCli.ts +83 -0
- package/src/startup.ts +10 -2
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* by Claude Code's session id, which the gateway reads from
|
|
18
18
|
* `CLAUDE_CODE_SESSION_ID`.
|
|
19
19
|
*
|
|
20
|
+
* With a ledger, PostToolUse also re-labels output that contains content a
|
|
21
|
+
* tainted session wrote earlier, and, when this session is tainted, records
|
|
22
|
+
* what the tool was given (file contents, edits, prompts) and the lines a
|
|
23
|
+
* shell command added to files.
|
|
24
|
+
*
|
|
20
25
|
* Errors fail closed: a PreToolUse hook that can't decide denies the call.
|
|
21
26
|
*/
|
|
22
27
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
@@ -29,7 +34,9 @@ import {
|
|
|
29
34
|
type Decision,
|
|
30
35
|
type PolicyDecision,
|
|
31
36
|
type SessionStore,
|
|
37
|
+
join,
|
|
32
38
|
} from "@adtl/labels";
|
|
39
|
+
import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
|
|
33
40
|
import type { AuditEntry, AuditSink } from "../audit.js";
|
|
34
41
|
import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
|
|
35
42
|
|
|
@@ -52,8 +59,10 @@ export interface HookOptions {
|
|
|
52
59
|
/** `audit`: decide and log, but never deny or ask. */
|
|
53
60
|
mode?: "active" | "audit";
|
|
54
61
|
audit?: AuditSink;
|
|
55
|
-
/**
|
|
56
|
-
|
|
62
|
+
/** Label ledger shared with the gateway. */
|
|
63
|
+
ledger?: Ledger;
|
|
64
|
+
/** Files the agent must not change: the ADTL config, the session directory and the ledger. */
|
|
65
|
+
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string };
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/** What the hook process prints and exits with. */
|
|
@@ -115,6 +124,10 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
115
124
|
if (protect.sessionsDir && inside(abs, resolve(protect.sessionsDir))) {
|
|
116
125
|
return { decision: "block", reason: "The agent may not change ADTL session labels" };
|
|
117
126
|
}
|
|
127
|
+
// Prefix match also covers SQLite's -wal and -shm files.
|
|
128
|
+
if (protect.ledgerPath && abs.startsWith(resolve(protect.ledgerPath))) {
|
|
129
|
+
return { decision: "block", reason: "The agent may not change the ADTL ledger" };
|
|
130
|
+
}
|
|
118
131
|
}
|
|
119
132
|
const command = typeof args.command === "string" ? args.command : undefined;
|
|
120
133
|
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
@@ -122,10 +135,27 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
122
135
|
if (marks.some((m) => command.includes(m))) {
|
|
123
136
|
return { decision: "ask", reason: "This command touches ADTL session labels" };
|
|
124
137
|
}
|
|
138
|
+
const ledgerMarks = ["ledger.db", "ledger declassify", protect.ledgerPath].filter((m): m is string => !!m);
|
|
139
|
+
if (ledgerMarks.some((m) => command.includes(m))) {
|
|
140
|
+
return { decision: "ask", reason: "This command touches the ADTL ledger" };
|
|
141
|
+
}
|
|
125
142
|
}
|
|
126
143
|
return undefined;
|
|
127
144
|
}
|
|
128
145
|
|
|
146
|
+
/** Text a shell command added to files, from Claude Code's `bashEditDiff` (one string per file). */
|
|
147
|
+
export function addedLines(output: unknown): string[] {
|
|
148
|
+
const files = isRecord(output) && isRecord(output.bashEditDiff) && Array.isArray(output.bashEditDiff.files) ? output.bashEditDiff.files : [];
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
const hunks = isRecord(f) && Array.isArray(f.hunks) ? f.hunks : [];
|
|
152
|
+
const lines = hunks.flatMap((h) => (isRecord(h) && Array.isArray(h.lines) ? h.lines : []));
|
|
153
|
+
const added = lines.filter((l): l is string => typeof l === "string" && l.startsWith("+")).map((l) => l.slice(1));
|
|
154
|
+
if (added.length) out.push(added.join("\n"));
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
129
159
|
export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
130
160
|
const event = input.hook_event_name;
|
|
131
161
|
if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
|
|
@@ -139,9 +169,22 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
|
139
169
|
const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const };
|
|
140
170
|
|
|
141
171
|
if (event !== "PreToolUse") {
|
|
142
|
-
const
|
|
172
|
+
const output = input.tool_response ?? input.error ?? null;
|
|
173
|
+
let label = labelResult({ ...route, result: output }, config);
|
|
174
|
+
let ledger: { chunks: number; writers: string[] } | undefined;
|
|
175
|
+
if (opts.ledger) {
|
|
176
|
+
const match = opts.ledger.lookup(valueHashes(output));
|
|
177
|
+
if (match) {
|
|
178
|
+
label = join(label, match.label);
|
|
179
|
+
ledger = { chunks: match.chunks, writers: match.writers };
|
|
180
|
+
}
|
|
181
|
+
// What the tool was given came from the session as it was before this output.
|
|
182
|
+
const before = opts.sessions.get(input.session_id);
|
|
183
|
+
const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
|
|
184
|
+
opts.ledger.record(written, before, input.session_id, route);
|
|
185
|
+
}
|
|
143
186
|
const sessionLabel = opts.sessions.observe(input.session_id, label);
|
|
144
|
-
opts.audit?.write({ ...base, outcome: "forwarded", label, sessionLabel, durationMs: 0 });
|
|
187
|
+
opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
|
|
145
188
|
return NOTHING;
|
|
146
189
|
}
|
|
147
190
|
|
package/src/hooks/run.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { FileSessionStore, defaultSessionsDir } from "@adtl/labels";
|
|
12
|
+
import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
12
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
13
14
|
import { parse as parseYaml } from "yaml";
|
|
14
15
|
import { JsonlAuditLog } from "../audit.js";
|
|
@@ -34,14 +35,18 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
34
35
|
const text = await readFile(path, "utf8");
|
|
35
36
|
const config = parseConfigText(text, path);
|
|
36
37
|
const gw = ((parseYaml(text) ?? {}) as { gateway?: { audit?: unknown; mode?: unknown } }).gateway ?? {};
|
|
37
|
-
const
|
|
38
|
+
const env = opts.env ?? process.env;
|
|
39
|
+
const sessionsDir = defaultSessionsDir(env);
|
|
40
|
+
const ledgerPath = config.ledger.path ? resolve(dirname(path), config.ledger.path) : defaultLedgerPath(env);
|
|
41
|
+
const ledger = config.ledger.enabled ? new Ledger(ledgerPath, { retentionDays: config.ledger.retentionDays }) : undefined;
|
|
38
42
|
return handleHook(input, {
|
|
39
43
|
config,
|
|
40
44
|
sessions: new FileSessionStore(sessionsDir),
|
|
45
|
+
...(ledger ? { ledger } : {}),
|
|
41
46
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
42
47
|
mode: gw.mode === "audit" ? "audit" : "active",
|
|
43
48
|
...(typeof gw.audit === "string" ? { audit: new JsonlAuditLog(resolve(dirname(path), gw.audit)) } : {}),
|
|
44
|
-
protect: { configPath: path, sessionsDir },
|
|
49
|
+
protect: { configPath: path, sessionsDir, ledgerPath },
|
|
45
50
|
});
|
|
46
51
|
} catch (err) {
|
|
47
52
|
return failure(input.hook_event_name, (err as Error).message);
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adtl-gateway init --from .mcp.json`: import an MCP client's server list
|
|
3
|
+
* (Claude Code's .mcp.json, or any file with `mcpServers`, such as Claude
|
|
4
|
+
* Desktop's config) into `gateway.upstreams`, and point the client at the
|
|
5
|
+
* gateway instead.
|
|
6
|
+
*
|
|
7
|
+
* - stdio servers keep their command, args and env; HTTP servers their url
|
|
8
|
+
* and headers. `${VAR}` and `${VAR:-default}` work the same in adtl.yaml.
|
|
9
|
+
* - SSE servers are left in the client config: the gateway speaks Streamable HTTP only.
|
|
10
|
+
* - An entry that already runs the gateway is recognized and kept.
|
|
11
|
+
* - Upstreams already in adtl.yaml are never changed.
|
|
12
|
+
*
|
|
13
|
+
* The new client config has the imported servers replaced by one entry that
|
|
14
|
+
* runs the gateway; everything else in the file is kept.
|
|
15
|
+
*/
|
|
16
|
+
import { dirname, relative, resolve } from "node:path";
|
|
17
|
+
import { isMap, type Document } from "yaml";
|
|
18
|
+
import type { Upstream } from "../config.js";
|
|
19
|
+
import { GATEWAY_VERSION } from "../gateway.js";
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_GATEWAY_NAME = "adtl";
|
|
22
|
+
|
|
23
|
+
export interface McpImport {
|
|
24
|
+
/** Servers moved into gateway.upstreams: name in the client config → upstream id. */
|
|
25
|
+
imported: { name: string; id: string }[];
|
|
26
|
+
/** Servers already in adtl.yaml under the same id; left as they are. */
|
|
27
|
+
kept: { name: string; id: string }[];
|
|
28
|
+
/** Servers left in the client config, with why. */
|
|
29
|
+
skipped: { name: string; reason: string }[];
|
|
30
|
+
/** The client config as it should be once the gateway replaces the imported servers. */
|
|
31
|
+
text: string;
|
|
32
|
+
changed: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Entry = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
function isRecord(v: unknown): v is Entry {
|
|
38
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function strings(v: unknown): string[] | undefined {
|
|
42
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? (v as string[]) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringRecord(v: unknown): Record<string, string> | undefined {
|
|
46
|
+
if (!isRecord(v)) return undefined;
|
|
47
|
+
return Object.values(v).every((x) => typeof x === "string") ? (v as Record<string, string>) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True if this client entry already runs adtl-gateway. */
|
|
51
|
+
export function isGatewayEntry(entry: Entry): boolean {
|
|
52
|
+
const words = [entry.command, ...(strings(entry.args) ?? [])].filter((w): w is string => typeof w === "string");
|
|
53
|
+
return words.some((w) => /(^|[/\\@])adtl-gateway$|@adtl\/gateway(@[^/\s]*)?$|packages[/\\]gateway[/\\]dist[/\\]cli\.js$/.test(w));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A valid upstream id (letters, digits, '-') for a client server name, unique among `taken`. */
|
|
57
|
+
export function upstreamId(name: string, taken: Set<string>): string {
|
|
58
|
+
const base = name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "server";
|
|
59
|
+
let id = base;
|
|
60
|
+
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
|
61
|
+
taken.add(id);
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The upstream for one client entry, or why it can't be one. */
|
|
66
|
+
export function toUpstream(entry: Entry, mcpDir: string, configDir: string): Upstream | { skip: string } {
|
|
67
|
+
const type = typeof entry.type === "string" ? entry.type : undefined;
|
|
68
|
+
if (type === "sse") return { skip: "the gateway doesn't support SSE servers; it speaks Streamable HTTP" };
|
|
69
|
+
if (type === "http" || (type === undefined && typeof entry.url === "string")) {
|
|
70
|
+
if (typeof entry.url !== "string") return { skip: "HTTP server without a url" };
|
|
71
|
+
const headers = stringRecord(entry.headers);
|
|
72
|
+
return { url: entry.url, headers: headers ?? {} };
|
|
73
|
+
}
|
|
74
|
+
if (type !== undefined && type !== "stdio") return { skip: `unknown server type "${type}"` };
|
|
75
|
+
if (typeof entry.command !== "string" || entry.command === "") return { skip: "no command or url" };
|
|
76
|
+
// Clients start stdio servers in the project directory; the gateway resolves cwd against adtl.yaml's.
|
|
77
|
+
const cwd = relative(configDir, mcpDir);
|
|
78
|
+
const args = strings(entry.args) ?? [];
|
|
79
|
+
const env = stringRecord(entry.env) ?? {};
|
|
80
|
+
return {
|
|
81
|
+
command: entry.command,
|
|
82
|
+
args,
|
|
83
|
+
env,
|
|
84
|
+
...(cwd !== "" ? { cwd } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What runs the gateway from the client config, with adtl.yaml given relative to the client config's directory. */
|
|
89
|
+
export function gatewayEntry(configPath: string, mcpDir: string): Entry {
|
|
90
|
+
return { command: "npx", args: ["-y", `@adtl/gateway@${GATEWAY_VERSION}`, "--config", relative(mcpDir, configPath) || configPath] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Add the client's servers to `doc` (adtl.yaml) under gateway.upstreams, and
|
|
95
|
+
* return the client config rewritten to use the gateway.
|
|
96
|
+
*/
|
|
97
|
+
export function importMcpServers(
|
|
98
|
+
mcpText: string,
|
|
99
|
+
mcpPath: string,
|
|
100
|
+
doc: Document,
|
|
101
|
+
configPath: string,
|
|
102
|
+
gatewayName = DEFAULT_GATEWAY_NAME,
|
|
103
|
+
): McpImport {
|
|
104
|
+
let json: unknown;
|
|
105
|
+
try {
|
|
106
|
+
json = JSON.parse(mcpText);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(`${mcpPath}: not valid JSON: ${(err as Error).message}`);
|
|
109
|
+
}
|
|
110
|
+
if (!isRecord(json) || !isRecord(json.mcpServers)) throw new Error(`${mcpPath}: no "mcpServers" object`);
|
|
111
|
+
|
|
112
|
+
const mcpDir = dirname(resolve(mcpPath));
|
|
113
|
+
const configDir = dirname(resolve(configPath));
|
|
114
|
+
const existing = doc.getIn(["gateway", "upstreams"]);
|
|
115
|
+
const taken = new Set<string>(isMap(existing) ? existing.items.map((p) => String((p.key as { value?: unknown })?.value ?? p.key)) : []);
|
|
116
|
+
const alreadyThere = new Set(taken);
|
|
117
|
+
|
|
118
|
+
const result: McpImport = { imported: [], kept: [], skipped: [], text: mcpText, changed: false };
|
|
119
|
+
const servers: Record<string, unknown> = {};
|
|
120
|
+
let gatewayKey: string | undefined;
|
|
121
|
+
|
|
122
|
+
for (const [name, entry] of Object.entries(json.mcpServers)) {
|
|
123
|
+
if (!isRecord(entry)) {
|
|
124
|
+
result.skipped.push({ name, reason: "not an object" });
|
|
125
|
+
servers[name] = entry;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (isGatewayEntry(entry)) {
|
|
129
|
+
gatewayKey ??= name;
|
|
130
|
+
servers[name] = entry;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const upstream = toUpstream(entry, mcpDir, configDir);
|
|
134
|
+
if ("skip" in upstream) {
|
|
135
|
+
result.skipped.push({ name, reason: upstream.skip });
|
|
136
|
+
servers[name] = entry;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const plain = name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
140
|
+
if (alreadyThere.has(plain)) {
|
|
141
|
+
result.kept.push({ name, id: plain });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const id = upstreamId(name, taken);
|
|
145
|
+
// Leave out empty fields; the gateway fills them in.
|
|
146
|
+
const written = Object.fromEntries(
|
|
147
|
+
Object.entries(upstream).filter(([, v]) => !(Array.isArray(v) ? v.length === 0 : isRecord(v) && Object.keys(v).length === 0)),
|
|
148
|
+
);
|
|
149
|
+
doc.setIn(["gateway", "upstreams", id], doc.createNode(written));
|
|
150
|
+
result.imported.push({ name, id });
|
|
151
|
+
}
|
|
152
|
+
// Block style, so long paths and secrets references stay readable (a new config starts with `upstreams: {}`).
|
|
153
|
+
const upstreams = doc.getIn(["gateway", "upstreams"]);
|
|
154
|
+
if (isMap(upstreams) && result.imported.length) upstreams.flow = false;
|
|
155
|
+
|
|
156
|
+
if (result.imported.length === 0 && result.kept.length === 0) return result;
|
|
157
|
+
if (gatewayKey === undefined) {
|
|
158
|
+
let key = gatewayName;
|
|
159
|
+
for (let n = 2; key in servers; n++) key = `${gatewayName}-${n}`;
|
|
160
|
+
servers[key] = gatewayEntry(resolve(configPath), mcpDir);
|
|
161
|
+
}
|
|
162
|
+
result.text = `${JSON.stringify({ ...json, mcpServers: servers }, null, 2)}\n`;
|
|
163
|
+
result.changed = result.text !== mcpText;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
package/src/init/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { PRESETS, choosePreset, type Preset } from "./presets.js";
|
|
2
2
|
export { mergeIntoDocument, ruleFromHints, suggest, type MergeReport, type ServerScan, type ServerSuggestion, type ToolSuggestion } from "./suggest.js";
|
|
3
3
|
export { runInit, type InitOptions, type InitResult } from "./run.js";
|
|
4
|
+
export { DEFAULT_GATEWAY_NAME, gatewayEntry, importMcpServers, isGatewayEntry, toUpstream, upstreamId, type McpImport } from "./fromMcpJson.js";
|
|
5
|
+
export { NEW_CONFIG } from "./run.js";
|
package/src/init/run.ts
CHANGED
|
@@ -2,31 +2,79 @@
|
|
|
2
2
|
* `adtl-gateway init`: connect to every upstream in adtl.yaml, list its
|
|
3
3
|
* tools, and draft a labeling rule for each tool not configured yet.
|
|
4
4
|
*
|
|
5
|
+
* With `from` (e.g. `.mcp.json`), the client's MCP servers are first imported
|
|
6
|
+
* into gateway.upstreams (see fromMcpJson.ts), and adtl.yaml is created if it
|
|
7
|
+
* doesn't exist yet.
|
|
8
|
+
*
|
|
5
9
|
* Without `write`, the updated file is only returned (the CLI prints it).
|
|
6
10
|
* With `write`, the original is kept as `<file>.bak` and the file is updated
|
|
7
|
-
* in place
|
|
11
|
+
* in place; with `from`, the client config is rewritten to use the gateway,
|
|
12
|
+
* also keeping a `.bak`. Existing entries and comments are never changed.
|
|
8
13
|
*/
|
|
9
14
|
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
-
import { dirname } from "node:path";
|
|
15
|
+
import { dirname, resolve } from "node:path";
|
|
11
16
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
17
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
13
18
|
import { parseDocument } from "yaml";
|
|
14
19
|
import { expandUpstream } from "../config.js";
|
|
15
|
-
import { connectUpstream,
|
|
20
|
+
import { GatewayConfigError, connectUpstream, parseGatewayConfigText } from "../startup.js";
|
|
21
|
+
import { importMcpServers, type McpImport } from "./fromMcpJson.js";
|
|
16
22
|
import { mergeIntoDocument, suggest, type MergeReport, type ServerScan } from "./suggest.js";
|
|
17
23
|
|
|
18
24
|
export interface InitOptions {
|
|
19
25
|
path: string;
|
|
20
26
|
write?: boolean;
|
|
21
27
|
env?: NodeJS.ProcessEnv;
|
|
28
|
+
/** An MCP client config with `mcpServers` (e.g. Claude Code's .mcp.json) to import upstreams from. */
|
|
29
|
+
from?: string;
|
|
30
|
+
/** Name for the gateway's entry in that client config. Default `adtl`. */
|
|
31
|
+
gatewayName?: string;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
/** Don't fold long values (paths, commands) across lines. */
|
|
35
|
+
const YAML_OPTIONS = { lineWidth: 0 } as const;
|
|
36
|
+
|
|
37
|
+
/** Starting point when `--from` creates adtl.yaml. */
|
|
38
|
+
export const NEW_CONFIG = `# ADTL config, drafted by \`adtl-gateway init --from\`. Review every rule marked "review".
|
|
39
|
+
#
|
|
40
|
+
# For every tool, say what its result is (integrity, confidentiality) and where
|
|
41
|
+
# calling it can send data (egress). Anything left out gets the defaults below.
|
|
42
|
+
|
|
43
|
+
defaults:
|
|
44
|
+
integrity: untrusted
|
|
45
|
+
confidentiality: private
|
|
46
|
+
|
|
47
|
+
policy:
|
|
48
|
+
default: allow
|
|
49
|
+
rules:
|
|
50
|
+
- name: no-public-write-after-untrusted
|
|
51
|
+
when:
|
|
52
|
+
session.integrity: untrusted
|
|
53
|
+
tool.egress: [public, external, unknown]
|
|
54
|
+
then: ask
|
|
55
|
+
reason: Session has read untrusted content; confirm before sending data out
|
|
56
|
+
|
|
57
|
+
- name: private-stays-private
|
|
58
|
+
when:
|
|
59
|
+
session.confidentiality: [private, secret]
|
|
60
|
+
tool.egress: public
|
|
61
|
+
then: block
|
|
62
|
+
reason: Private data must not reach a public tool
|
|
63
|
+
|
|
64
|
+
gateway:
|
|
65
|
+
upstreams: {}
|
|
66
|
+
`;
|
|
67
|
+
|
|
24
68
|
export interface InitResult {
|
|
25
69
|
/** The updated adtl.yaml. */
|
|
26
70
|
text: string;
|
|
27
71
|
reports: MergeReport[];
|
|
28
72
|
/** Upstreams that could not be scanned; left untouched in the file. */
|
|
29
73
|
failed: { id: string; error: string }[];
|
|
74
|
+
/** With `from`: what was imported, and the rewritten client config. */
|
|
75
|
+
mcp?: McpImport & { path: string };
|
|
76
|
+
/** True if adtl.yaml didn't exist and was drafted from scratch. */
|
|
77
|
+
created?: boolean;
|
|
30
78
|
}
|
|
31
79
|
|
|
32
80
|
async function listAllTools(client: Client): Promise<Tool[]> {
|
|
@@ -53,10 +101,35 @@ async function scan(id: string, connect: () => Promise<Client>): Promise<ServerS
|
|
|
53
101
|
|
|
54
102
|
export async function runInit(opts: InitOptions): Promise<InitResult> {
|
|
55
103
|
const env = opts.env ?? process.env;
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
104
|
+
const path = resolve(opts.path);
|
|
105
|
+
let original: string | undefined;
|
|
106
|
+
try {
|
|
107
|
+
original = await readFile(path, "utf8");
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT" || !opts.from) {
|
|
110
|
+
throw new GatewayConfigError(`Cannot read config file ${path}: ${(err as Error).message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const doc = parseDocument(original ?? NEW_CONFIG);
|
|
59
114
|
|
|
115
|
+
let mcp: InitResult["mcp"];
|
|
116
|
+
if (opts.from) {
|
|
117
|
+
const mcpPath = resolve(opts.from);
|
|
118
|
+
let mcpText: string;
|
|
119
|
+
try {
|
|
120
|
+
mcpText = await readFile(mcpPath, "utf8");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new GatewayConfigError(`Cannot read ${mcpPath}: ${(err as Error).message}`);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
mcp = { path: mcpPath, ...importMcpServers(mcpText, mcpPath, doc, path, opts.gatewayName) };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new GatewayConfigError((err as Error).message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const loaded = parseGatewayConfigText(doc.toString(YAML_OPTIONS), path);
|
|
132
|
+
const baseDir = dirname(loaded.path);
|
|
60
133
|
const ids = Object.keys(loaded.gateway.upstreams);
|
|
61
134
|
const settled = await Promise.allSettled(
|
|
62
135
|
ids.map((id) => scan(id, () => connectUpstream(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir))),
|
|
@@ -68,12 +141,18 @@ export async function runInit(opts: InitOptions): Promise<InitResult> {
|
|
|
68
141
|
else failed.push({ id: ids[i]!, error: (s.reason as Error).message });
|
|
69
142
|
});
|
|
70
143
|
|
|
71
|
-
const doc = parseDocument(original);
|
|
72
144
|
const reports = mergeIntoDocument(doc, scans.map((s) => suggest(s)));
|
|
73
|
-
const text = doc.toString();
|
|
74
|
-
if (opts.write
|
|
75
|
-
await writeFile(
|
|
76
|
-
|
|
145
|
+
const text = doc.toString(YAML_OPTIONS);
|
|
146
|
+
if (opts.write) {
|
|
147
|
+
if (original === undefined) await writeFile(path, text, "utf8");
|
|
148
|
+
else if (text !== original) {
|
|
149
|
+
await writeFile(`${path}.bak`, original, "utf8");
|
|
150
|
+
await writeFile(path, text, "utf8");
|
|
151
|
+
}
|
|
152
|
+
if (mcp?.changed) {
|
|
153
|
+
await writeFile(`${mcp.path}.bak`, await readFile(mcp.path, "utf8"), "utf8");
|
|
154
|
+
await writeFile(mcp.path, mcp.text, "utf8");
|
|
155
|
+
}
|
|
77
156
|
}
|
|
78
|
-
return { text, reports, failed };
|
|
157
|
+
return { text, reports, failed, ...(mcp ? { mcp } : {}), ...(original === undefined ? { created: true } : {}) };
|
|
79
158
|
}
|
package/src/ledgerCli.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adtl-gateway ledger`: look after the label ledger by hand.
|
|
3
|
+
*
|
|
4
|
+
* adtl-gateway ledger stats [-c adtl.yaml]
|
|
5
|
+
* adtl-gateway ledger verify [-c adtl.yaml]
|
|
6
|
+
* adtl-gateway ledger declassify <file>... [-c adtl.yaml] [--reason <text>]
|
|
7
|
+
*
|
|
8
|
+
* `declassify` clears the label of each file's current content, after you've
|
|
9
|
+
* checked it. It is meant to be run by a person; the Claude Code hook asks
|
|
10
|
+
* before letting the agent run it.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { userInfo } from "node:os";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import { parseConfigText } from "@adtl/interceptors";
|
|
16
|
+
import { Ledger, chunkHashes, defaultLedgerPath } from "@adtl/ledger";
|
|
17
|
+
|
|
18
|
+
export const LEDGER_USAGE = `Usage: adtl-gateway ledger stats [-c <adtl.yaml>]
|
|
19
|
+
adtl-gateway ledger verify [-c <adtl.yaml>]
|
|
20
|
+
adtl-gateway ledger declassify <file>... [-c <adtl.yaml>] [--reason <text>]
|
|
21
|
+
|
|
22
|
+
The ledger remembers content written by sessions that had read untrusted or
|
|
23
|
+
private data, and re-labels it when it is read back. It lives in
|
|
24
|
+
ledger.path from the config, else ADTL_LEDGER, else ~/.adtl/ledger.db.
|
|
25
|
+
|
|
26
|
+
stats Count entries.
|
|
27
|
+
verify Check that no entry was changed or removed (exit 1 if one was).
|
|
28
|
+
declassify Clear the label of each file's current content. Check the
|
|
29
|
+
files first: after this, ADTL treats that content as clean.`;
|
|
30
|
+
|
|
31
|
+
export interface LedgerCommandResult {
|
|
32
|
+
stdout: string;
|
|
33
|
+
exitCode: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runLedgerCommand(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<LedgerCommandResult> {
|
|
37
|
+
const [cmd, ...rest] = argv;
|
|
38
|
+
let config: string | undefined;
|
|
39
|
+
let reason: string | undefined;
|
|
40
|
+
const files: string[] = [];
|
|
41
|
+
for (let i = 0; i < rest.length; i++) {
|
|
42
|
+
const a = rest[i]!;
|
|
43
|
+
if (a === "-c" || a === "--config") config = rest[++i];
|
|
44
|
+
else if (a === "--reason") reason = rest[++i];
|
|
45
|
+
else if (a.startsWith("-")) throw new Error(`Unknown argument: ${a}\n\n${LEDGER_USAGE}`);
|
|
46
|
+
else files.push(a);
|
|
47
|
+
}
|
|
48
|
+
if (cmd === undefined || cmd === "-h" || cmd === "--help") return { stdout: LEDGER_USAGE, exitCode: 0 };
|
|
49
|
+
if (!["stats", "verify", "declassify"].includes(cmd)) throw new Error(`Unknown ledger command: ${cmd}\n\n${LEDGER_USAGE}`);
|
|
50
|
+
if (cmd === "declassify" && files.length === 0) throw new Error(`declassify needs at least one file\n\n${LEDGER_USAGE}`);
|
|
51
|
+
if (cmd !== "declassify" && files.length > 0) throw new Error(`Unexpected argument: ${files[0]}\n\n${LEDGER_USAGE}`);
|
|
52
|
+
|
|
53
|
+
let path = defaultLedgerPath(env);
|
|
54
|
+
let retentionDays: number | undefined;
|
|
55
|
+
if (config) {
|
|
56
|
+
const abs = resolve(config);
|
|
57
|
+
const parsed = parseConfigText(await readFile(abs, "utf8"), abs);
|
|
58
|
+
if (parsed.ledger.path) path = resolve(dirname(abs), parsed.ledger.path);
|
|
59
|
+
retentionDays = parsed.ledger.retentionDays;
|
|
60
|
+
}
|
|
61
|
+
const ledger = new Ledger(path, retentionDays ? { retentionDays } : {});
|
|
62
|
+
try {
|
|
63
|
+
if (cmd === "stats") {
|
|
64
|
+
const s = ledger.stats();
|
|
65
|
+
return { stdout: `${path}\n${s.records} records from ${s.writers} sessions, ${s.clears} declassified chunks (${s.entries} entries)`, exitCode: 0 };
|
|
66
|
+
}
|
|
67
|
+
if (cmd === "verify") {
|
|
68
|
+
const v = ledger.verify();
|
|
69
|
+
return v.ok
|
|
70
|
+
? { stdout: `${path}: ${v.entries} entries, chain intact`, exitCode: 0 }
|
|
71
|
+
: { stdout: `${path}: TAMPERED: ${v.problem}`, exitCode: 1 };
|
|
72
|
+
}
|
|
73
|
+
const by = `user:${userInfo().username}`;
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
const n = ledger.declassify(chunkHashes(await readFile(resolve(f), "utf8")), by, reason);
|
|
77
|
+
lines.push(n ? `${f}: cleared ${n} labeled chunk${n === 1 ? "" : "s"}` : `${f}: nothing labeled`);
|
|
78
|
+
}
|
|
79
|
+
return { stdout: lines.join("\n"), exitCode: 0 };
|
|
80
|
+
} finally {
|
|
81
|
+
ledger.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/startup.ts
CHANGED
|
@@ -49,6 +49,12 @@ export async function loadGatewayConfig(path: string): Promise<LoadedConfig> {
|
|
|
49
49
|
} catch (err) {
|
|
50
50
|
throw new GatewayConfigError(`Cannot read config file ${abs}: ${(err as Error).message}`);
|
|
51
51
|
}
|
|
52
|
+
return parseGatewayConfigText(text, abs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Validate adtl.yaml text; `path` is where it lives (relative paths in it resolve against its directory). */
|
|
56
|
+
export function parseGatewayConfigText(text: string, path: string): LoadedConfig {
|
|
57
|
+
const abs = resolve(path);
|
|
52
58
|
let labels: AdtlConfig;
|
|
53
59
|
try {
|
|
54
60
|
labels = parseConfigText(text, abs);
|
|
@@ -146,12 +152,14 @@ export async function startGateway(
|
|
|
146
152
|
// 1. Interceptor server (out of process, per the Interceptors WG).
|
|
147
153
|
const shared = sharedSession(env);
|
|
148
154
|
const custom = loaded.gateway.interceptors && expandUpstream("interceptors", loaded.gateway.interceptors, env);
|
|
155
|
+
// 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 } : {};
|
|
149
157
|
const icfg: StdioUpstream = custom
|
|
150
|
-
? { ...custom, env: { ...custom.env, ...(shared ? { ADTL_SESSIONS_DIR: shared.dir } : {}) } }
|
|
158
|
+
? { ...custom, env: { ...ledgerEnv, ...custom.env, ...(shared ? { ADTL_SESSIONS_DIR: shared.dir } : {}) } }
|
|
151
159
|
: {
|
|
152
160
|
command: process.execPath,
|
|
153
161
|
args: [bundledInterceptorsCli(), "--config", loaded.path, ...(shared ? ["--sessions-dir", shared.dir] : [])],
|
|
154
|
-
env:
|
|
162
|
+
env: ledgerEnv,
|
|
155
163
|
};
|
|
156
164
|
const interceptors = await connectUpstream("interceptors", icfg, baseDir);
|
|
157
165
|
clients.push(interceptors);
|