@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.
Files changed (58) hide show
  1. package/README.md +45 -1
  2. package/dist/audit.d.ts +2 -0
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js.map +1 -1
  5. package/dist/cli.js +54 -15
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +49 -44
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +10 -3
  10. package/dist/config.js.map +1 -1
  11. package/dist/gateway.d.ts +1 -1
  12. package/dist/gateway.d.ts.map +1 -1
  13. package/dist/gateway.js +3 -2
  14. package/dist/gateway.js.map +1 -1
  15. package/dist/hooks/claudeCode.d.ts +7 -1
  16. package/dist/hooks/claudeCode.d.ts.map +1 -1
  17. package/dist/hooks/claudeCode.js +43 -3
  18. package/dist/hooks/claudeCode.js.map +1 -1
  19. package/dist/hooks/run.d.ts.map +1 -1
  20. package/dist/hooks/run.js +7 -2
  21. package/dist/hooks/run.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/init/fromMcpJson.d.ts +41 -0
  27. package/dist/init/fromMcpJson.d.ts.map +1 -0
  28. package/dist/init/fromMcpJson.js +141 -0
  29. package/dist/init/fromMcpJson.js.map +1 -0
  30. package/dist/init/index.d.ts +2 -0
  31. package/dist/init/index.d.ts.map +1 -1
  32. package/dist/init/index.js +2 -0
  33. package/dist/init/index.js.map +1 -1
  34. package/dist/init/run.d.ts +13 -0
  35. package/dist/init/run.d.ts.map +1 -1
  36. package/dist/init/run.js +83 -11
  37. package/dist/init/run.js.map +1 -1
  38. package/dist/ledgerCli.d.ts +7 -0
  39. package/dist/ledgerCli.d.ts.map +1 -0
  40. package/dist/ledgerCli.js +86 -0
  41. package/dist/ledgerCli.js.map +1 -0
  42. package/dist/startup.d.ts +2 -0
  43. package/dist/startup.d.ts.map +1 -1
  44. package/dist/startup.js +9 -2
  45. package/dist/startup.js.map +1 -1
  46. package/package.json +6 -5
  47. package/src/audit.ts +2 -0
  48. package/src/cli.ts +46 -15
  49. package/src/config.ts +9 -3
  50. package/src/gateway.ts +3 -2
  51. package/src/hooks/claudeCode.ts +47 -4
  52. package/src/hooks/run.ts +7 -2
  53. package/src/index.ts +1 -0
  54. package/src/init/fromMcpJson.ts +165 -0
  55. package/src/init/index.ts +2 -0
  56. package/src/init/run.ts +91 -12
  57. package/src/ledgerCli.ts +83 -0
  58. package/src/startup.ts +10 -2
@@ -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
- /** Files the agent must not change: the ADTL config and the session directory. */
56
- protect?: { configPath?: string; sessionsDir?: string };
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 label = labelResult({ ...route, result: input.tool_response ?? input.error ?? null }, config);
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 sessionsDir = defaultSessionsDir(opts.env ?? process.env);
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
@@ -8,3 +8,4 @@ export * from "./gateway.js";
8
8
  export * from "./startup.js";
9
9
  export * from "./init/index.js";
10
10
  export * from "./hooks/index.js";
11
+ export * from "./ledgerCli.js";
@@ -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. Existing entries and comments are never changed.
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, loadGatewayConfig } from "../startup.js";
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 loaded = await loadGatewayConfig(opts.path);
57
- const baseDir = dirname(loaded.path);
58
- const original = await readFile(loaded.path, "utf8");
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 && text !== original) {
75
- await writeFile(`${loaded.path}.bak`, original, "utf8");
76
- await writeFile(loaded.path, text, "utf8");
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
  }
@@ -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);