pombus 0.0.1 → 0.1.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/dist/bin.js +157 -0
- package/dist/bin.test.js +33 -0
- package/dist/client.js +142 -0
- package/dist/clients.js +187 -0
- package/dist/doctor.js +34 -0
- package/dist/i18n.js +141 -0
- package/dist/i18n.test.js +78 -0
- package/dist/init.js +154 -0
- package/dist/installer.test.js +344 -0
- package/dist/scope.js +118 -0
- package/dist/security.test.js +111 -0
- package/dist/server.js +94 -0
- package/dist/testutil.js +117 -0
- package/dist/tools.js +955 -0
- package/dist/tools.test.js +380 -0
- package/package.json +30 -17
- package/README.md +0 -5
- package/index.js +0 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { runInit } from "./init.js";
|
|
7
|
+
import { runDoctor } from "./doctor.js";
|
|
8
|
+
import { clientTargets, writeJsonConfig, isInsideHome, printableJson, printableCodexToml, ConfigWriteError, } from "./clients.js";
|
|
9
|
+
import { FakeCore, authMeInbox, authMePod, createdInbox } from "./testutil.js";
|
|
10
|
+
// Obviously-fake tokens — never a real secret (AGENTS.md §9).
|
|
11
|
+
const BROAD_KEY = "pk_test_broad_pod_scope_FAKE";
|
|
12
|
+
const BASE = "https://core.test/v0";
|
|
13
|
+
/** A temp HOME, cleaned up after each use. */
|
|
14
|
+
function tempHome() {
|
|
15
|
+
const home = mkdtempSync(join(tmpdir(), "pombus-home-"));
|
|
16
|
+
return { home, cleanup: () => rmSync(home, { recursive: true, force: true }) };
|
|
17
|
+
}
|
|
18
|
+
/** A line sink that records init/doctor human output for assertions. */
|
|
19
|
+
function sink() {
|
|
20
|
+
const lines = [];
|
|
21
|
+
return { lines, out: (l) => lines.push(l) };
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------------------------------
|
|
24
|
+
// clients.ts — the write is the sharp edge: merge, idempotent, atomic-ish, 0600, never outside HOME.
|
|
25
|
+
// ---------------------------------------------------------------------------------------------------
|
|
26
|
+
test("writeJsonConfig MERGES — preserves every existing key, adds only mcpServers.pombus", () => {
|
|
27
|
+
const { home, cleanup } = tempHome();
|
|
28
|
+
try {
|
|
29
|
+
const path = join(home, ".claude.json");
|
|
30
|
+
writeFileSync(path, JSON.stringify({
|
|
31
|
+
projects: { "/some/proj": { history: [1, 2, 3] } },
|
|
32
|
+
theme: "dark",
|
|
33
|
+
mcpServers: { other: { command: "node", args: ["x.js"] } },
|
|
34
|
+
}));
|
|
35
|
+
writeJsonConfig(path, home, BROAD_KEY);
|
|
36
|
+
const after = JSON.parse(readFileSync(path, "utf8"));
|
|
37
|
+
// Untouched keys survive byte-for-value.
|
|
38
|
+
assert.deepEqual(after["projects"], { "/some/proj": { history: [1, 2, 3] } });
|
|
39
|
+
assert.equal(after["theme"], "dark");
|
|
40
|
+
// The pre-existing OTHER server survives; ours is added alongside.
|
|
41
|
+
assert.deepEqual(after["mcpServers"]["other"], { command: "node", args: ["x.js"] });
|
|
42
|
+
assert.deepEqual(after["mcpServers"]["pombus"], {
|
|
43
|
+
command: "npx",
|
|
44
|
+
args: ["-y", "pombus", "mcp"],
|
|
45
|
+
env: { POMBUS_API_KEY: BROAD_KEY },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
cleanup();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
test("writeJsonConfig is IDEMPOTENT — running twice yields exactly one pombus entry", () => {
|
|
53
|
+
const { home, cleanup } = tempHome();
|
|
54
|
+
try {
|
|
55
|
+
const path = join(home, ".cursor", "mcp.json");
|
|
56
|
+
writeJsonConfig(path, home, BROAD_KEY);
|
|
57
|
+
writeJsonConfig(path, home, BROAD_KEY);
|
|
58
|
+
const after = JSON.parse(readFileSync(path, "utf8"));
|
|
59
|
+
const servers = after["mcpServers"];
|
|
60
|
+
assert.deepEqual(Object.keys(servers), ["pombus"]);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
cleanup();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
test("writeJsonConfig creates a fresh file when none exists (and its parent dir)", () => {
|
|
67
|
+
const { home, cleanup } = tempHome();
|
|
68
|
+
try {
|
|
69
|
+
const path = join(home, ".gemini", "settings.json");
|
|
70
|
+
writeJsonConfig(path, home, BROAD_KEY);
|
|
71
|
+
const after = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
+
assert.equal(after["mcpServers"]["pombus"]["env"]["POMBUS_API_KEY"], BROAD_KEY);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
cleanup();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
test("writeJsonConfig REFUSES to clobber a corrupt existing config (never destroys real state)", () => {
|
|
79
|
+
const { home, cleanup } = tempHome();
|
|
80
|
+
try {
|
|
81
|
+
const path = join(home, ".claude.json");
|
|
82
|
+
writeFileSync(path, "{ this is not valid json ");
|
|
83
|
+
assert.throws(() => writeJsonConfig(path, home, BROAD_KEY), (e) => e instanceof ConfigWriteError && e.code === "corrupt_config");
|
|
84
|
+
// The broken file is left exactly as it was — not overwritten.
|
|
85
|
+
assert.equal(readFileSync(path, "utf8"), "{ this is not valid json ");
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
cleanup();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
test("writeJsonConfig REFUSES a target outside HOME (never writes into a project repo)", () => {
|
|
92
|
+
const { home, cleanup } = tempHome();
|
|
93
|
+
try {
|
|
94
|
+
const outside = join(tmpdir(), "not-home-" + process.pid, "mcp.json");
|
|
95
|
+
assert.throws(() => writeJsonConfig(outside, home, BROAD_KEY), (e) => e instanceof ConfigWriteError && e.code === "outside_home");
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
test("isInsideHome accepts HOME and children, rejects siblings and the project tree", () => {
|
|
102
|
+
const home = join(tmpdir(), "h");
|
|
103
|
+
assert.ok(isInsideHome(join(home, ".cursor", "mcp.json"), home));
|
|
104
|
+
assert.ok(isInsideHome(home, home));
|
|
105
|
+
assert.ok(!isInsideHome(join(tmpdir(), "hother", "x.json"), home));
|
|
106
|
+
assert.ok(!isInsideHome("/etc/mcp.json", home));
|
|
107
|
+
});
|
|
108
|
+
test("the config file carrying the key is 0600 on POSIX (owner-only)", { skip: process.platform === "win32" }, () => {
|
|
109
|
+
const { home, cleanup } = tempHome();
|
|
110
|
+
try {
|
|
111
|
+
const path = join(home, ".cursor", "mcp.json");
|
|
112
|
+
writeJsonConfig(path, home, BROAD_KEY);
|
|
113
|
+
const mode = statSync(path).mode & 0o777;
|
|
114
|
+
assert.equal(mode, 0o600, `expected 0600, got ${mode.toString(8)}`);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
cleanup();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
test("the client table: claude-code/cursor/gemini are WRITE (json), codex is PRINT (toml)", () => {
|
|
121
|
+
const home = join(tmpdir(), "h");
|
|
122
|
+
const t = clientTargets(home);
|
|
123
|
+
const by = Object.fromEntries(t.map((c) => [c.id, c]));
|
|
124
|
+
assert.equal(by["claude-code"].mode, "json");
|
|
125
|
+
assert.equal(by["cursor"].mode, "json");
|
|
126
|
+
assert.equal(by["gemini"].mode, "json");
|
|
127
|
+
assert.equal(by["codex"].mode, "print");
|
|
128
|
+
// Every target carries a cited source and a path under HOME.
|
|
129
|
+
for (const c of t) {
|
|
130
|
+
assert.ok(c.source.length > 0, `${c.id} missing source`);
|
|
131
|
+
assert.ok(isInsideHome(c.path, home), `${c.id} path escapes HOME`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("printable blocks: JSON has the standard mcpServers wrapper; Codex TOML is a [mcp_servers.pombus] table", () => {
|
|
135
|
+
const j = JSON.parse(printableJson("<KEY>"));
|
|
136
|
+
assert.deepEqual(j.mcpServers.pombus, {
|
|
137
|
+
command: "npx",
|
|
138
|
+
args: ["-y", "pombus", "mcp"],
|
|
139
|
+
env: { POMBUS_API_KEY: "<KEY>" },
|
|
140
|
+
});
|
|
141
|
+
const toml = printableCodexToml("<KEY>");
|
|
142
|
+
assert.ok(toml.includes("[mcp_servers.pombus]"));
|
|
143
|
+
assert.ok(toml.includes('command = "npx"'));
|
|
144
|
+
assert.ok(toml.includes("POMBUS_API_KEY = '<KEY>'"));
|
|
145
|
+
});
|
|
146
|
+
// ---------------------------------------------------------------------------------------------------
|
|
147
|
+
// runInit key contract (option A): the key the user provided is persisted verbatim — no mint, no
|
|
148
|
+
// narrowing. The agent is the account's full operator and runs with the key the user chose to give.
|
|
149
|
+
// ---------------------------------------------------------------------------------------------------
|
|
150
|
+
test("init persists EXACTLY the provided key — never mints a new one (no POST /api-keys)", async () => {
|
|
151
|
+
const { home, cleanup } = tempHome();
|
|
152
|
+
try {
|
|
153
|
+
// --create-inbox on a pod key: an inbox is created, but the key written is still the given one.
|
|
154
|
+
const fake = new FakeCore()
|
|
155
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMePod())
|
|
156
|
+
.on("POST", (u) => u.pathname.endsWith("/inboxes"), createdInbox("inb_new", "agent@pombus.com"));
|
|
157
|
+
const { out } = sink();
|
|
158
|
+
const res = await runInit({
|
|
159
|
+
apiKey: BROAD_KEY,
|
|
160
|
+
createInbox: true,
|
|
161
|
+
client: "cursor",
|
|
162
|
+
home,
|
|
163
|
+
baseUrl: BASE,
|
|
164
|
+
fetchImpl: fake.fetch,
|
|
165
|
+
locale: "en-US",
|
|
166
|
+
out,
|
|
167
|
+
});
|
|
168
|
+
assert.deepEqual(res.written, ["cursor"]);
|
|
169
|
+
assert.equal(res.inboxId, "inb_new"); // --create-inbox still works, independent of the key
|
|
170
|
+
const cfg = JSON.parse(readFileSync(join(home, ".cursor", "mcp.json"), "utf8"));
|
|
171
|
+
assert.equal(cfg.mcpServers.pombus.env.POMBUS_API_KEY, BROAD_KEY, "the provided key is persisted as-is");
|
|
172
|
+
// No key was minted — the installer never hits POST /api-keys.
|
|
173
|
+
assert.ok(!fake.requests.some((r) => r.method === "POST" && r.url.includes("/api-keys")));
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
cleanup();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
test("init with an inbox-scoped key persists it as-is (no /api-keys call)", async () => {
|
|
180
|
+
const { home, cleanup } = tempHome();
|
|
181
|
+
try {
|
|
182
|
+
const fake = new FakeCore().on("GET", (u) => u.pathname.endsWith("/auth/me"), authMeInbox("inb_fixed"));
|
|
183
|
+
const { out } = sink();
|
|
184
|
+
const res = await runInit({
|
|
185
|
+
apiKey: BROAD_KEY, // the value is opaque to init; it is written verbatim
|
|
186
|
+
client: "cursor",
|
|
187
|
+
home,
|
|
188
|
+
baseUrl: BASE,
|
|
189
|
+
fetchImpl: fake.fetch,
|
|
190
|
+
locale: "en-US",
|
|
191
|
+
out,
|
|
192
|
+
});
|
|
193
|
+
assert.deepEqual(res.written, ["cursor"]);
|
|
194
|
+
assert.ok(!fake.requests.some((r) => r.url.includes("/api-keys")));
|
|
195
|
+
const cfg = JSON.parse(readFileSync(join(home, ".cursor", "mcp.json"), "utf8"));
|
|
196
|
+
assert.equal(cfg.mcpServers.pombus.env.POMBUS_API_KEY, BROAD_KEY);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
cleanup();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
// ---------------------------------------------------------------------------------------------------
|
|
203
|
+
// --print writes NOTHING.
|
|
204
|
+
// ---------------------------------------------------------------------------------------------------
|
|
205
|
+
test("init --print writes NO file and emits the block", async () => {
|
|
206
|
+
const { home, cleanup } = tempHome();
|
|
207
|
+
try {
|
|
208
|
+
const fake = new FakeCore().on("GET", (u) => u.pathname.endsWith("/auth/me"), authMeInbox("inb_fixed"));
|
|
209
|
+
const { lines, out } = sink();
|
|
210
|
+
const res = await runInit({
|
|
211
|
+
apiKey: BROAD_KEY,
|
|
212
|
+
print: true,
|
|
213
|
+
client: "cursor",
|
|
214
|
+
home,
|
|
215
|
+
baseUrl: BASE,
|
|
216
|
+
fetchImpl: fake.fetch,
|
|
217
|
+
locale: "en-US",
|
|
218
|
+
out,
|
|
219
|
+
});
|
|
220
|
+
assert.equal(res.printed, true);
|
|
221
|
+
assert.deepEqual(res.written, []);
|
|
222
|
+
// No config file was created.
|
|
223
|
+
assert.throws(() => readFileSync(join(home, ".cursor", "mcp.json"), "utf8"));
|
|
224
|
+
assert.ok(lines.some((l) => l.includes('"pombus"')));
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
cleanup();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
test("init --print with NO key emits a placeholder block, makes no network call, writes nothing", async () => {
|
|
231
|
+
const { home, cleanup } = tempHome();
|
|
232
|
+
try {
|
|
233
|
+
const fake = new FakeCore().on("GET", () => true, { json: {} });
|
|
234
|
+
const { lines, out } = sink();
|
|
235
|
+
const res = await runInit({
|
|
236
|
+
print: true,
|
|
237
|
+
client: "cursor",
|
|
238
|
+
home,
|
|
239
|
+
baseUrl: BASE,
|
|
240
|
+
fetchImpl: fake.fetch,
|
|
241
|
+
locale: "en-US",
|
|
242
|
+
out,
|
|
243
|
+
});
|
|
244
|
+
assert.equal(res.printed, true);
|
|
245
|
+
assert.equal(fake.requests.length, 0, "no network call without a key");
|
|
246
|
+
assert.ok(lines.some((l) => l.includes("<YOUR_POMBUS_API_KEY>")));
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
cleanup();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
test("init on a PRINT-only client (codex) prints TOML and writes no file", async () => {
|
|
253
|
+
const { home, cleanup } = tempHome();
|
|
254
|
+
try {
|
|
255
|
+
const fake = new FakeCore().on("GET", (u) => u.pathname.endsWith("/auth/me"), authMeInbox("inb_fixed"));
|
|
256
|
+
const { lines, out } = sink();
|
|
257
|
+
const res = await runInit({
|
|
258
|
+
apiKey: BROAD_KEY,
|
|
259
|
+
client: "codex",
|
|
260
|
+
home,
|
|
261
|
+
baseUrl: BASE,
|
|
262
|
+
fetchImpl: fake.fetch,
|
|
263
|
+
locale: "en-US",
|
|
264
|
+
out,
|
|
265
|
+
});
|
|
266
|
+
assert.deepEqual(res.written, []);
|
|
267
|
+
assert.deepEqual(res.printedFor, ["codex"]);
|
|
268
|
+
assert.throws(() => readFileSync(join(home, ".codex", "config.toml"), "utf8"));
|
|
269
|
+
assert.ok(lines.some((l) => l.includes("[mcp_servers.pombus]")));
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
cleanup();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
// ---------------------------------------------------------------------------------------------------
|
|
276
|
+
// doctor — reports scope / errors, read-only.
|
|
277
|
+
// ---------------------------------------------------------------------------------------------------
|
|
278
|
+
test("doctor reports the scope of a valid key", async () => {
|
|
279
|
+
const fake = new FakeCore().on("GET", (u) => u.pathname.endsWith("/auth/me"), authMePod());
|
|
280
|
+
const { lines, out } = sink();
|
|
281
|
+
const res = await runDoctor({ apiKey: BROAD_KEY, baseUrl: BASE, fetchImpl: fake.fetch, locale: "en-US", out });
|
|
282
|
+
assert.equal(res.ok, true);
|
|
283
|
+
assert.equal(res.scope, "pod");
|
|
284
|
+
assert.ok(lines.some((l) => l.includes("pod")));
|
|
285
|
+
// Read-only: the single request was the /auth/me GET.
|
|
286
|
+
assert.equal(fake.requests.length, 1);
|
|
287
|
+
assert.equal(fake.requests[0].method, "GET");
|
|
288
|
+
});
|
|
289
|
+
test("doctor with no key reports no_api_key and makes no network call", async () => {
|
|
290
|
+
const fake = new FakeCore().on("GET", () => true, { json: {} });
|
|
291
|
+
const { out } = sink();
|
|
292
|
+
const res = await runDoctor({ baseUrl: BASE, fetchImpl: fake.fetch, locale: "en-US", out });
|
|
293
|
+
assert.equal(res.ok, false);
|
|
294
|
+
assert.equal(res.code, "no_api_key");
|
|
295
|
+
assert.equal(fake.requests.length, 0);
|
|
296
|
+
});
|
|
297
|
+
test("doctor with an invalid key reports auth_failed (401/403/404 collapse to not_found -> auth_failed)", async () => {
|
|
298
|
+
const fake = new FakeCore().on("GET", (u) => u.pathname.endsWith("/auth/me"), { status: 401, json: { code: "unauthorized" } });
|
|
299
|
+
const { out } = sink();
|
|
300
|
+
const res = await runDoctor({ apiKey: "pk_test_bad_FAKE", baseUrl: BASE, fetchImpl: fake.fetch, locale: "en-US", out });
|
|
301
|
+
assert.equal(res.ok, false);
|
|
302
|
+
assert.equal(res.code, "auth_failed");
|
|
303
|
+
});
|
|
304
|
+
test("doctor with an unreachable core reports network", async () => {
|
|
305
|
+
const fetchImpl = async () => {
|
|
306
|
+
throw new TypeError("fetch failed");
|
|
307
|
+
};
|
|
308
|
+
const { out } = sink();
|
|
309
|
+
const res = await runDoctor({ apiKey: "pk_test_x_FAKE", baseUrl: BASE, fetchImpl, locale: "en-US", out });
|
|
310
|
+
assert.equal(res.ok, false);
|
|
311
|
+
assert.equal(res.code, "network");
|
|
312
|
+
});
|
|
313
|
+
// ---------------------------------------------------------------------------------------------------
|
|
314
|
+
// the key never leaks into human output (log hygiene, AGENTS.md §5).
|
|
315
|
+
// ---------------------------------------------------------------------------------------------------
|
|
316
|
+
test("no human line printed by init or doctor ever contains the BROAD key", async () => {
|
|
317
|
+
const { home, cleanup } = tempHome();
|
|
318
|
+
try {
|
|
319
|
+
const fake = new FakeCore()
|
|
320
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMePod())
|
|
321
|
+
.on("POST", (u) => u.pathname.endsWith("/inboxes"), createdInbox("inb_new", "agent@pombus.com"));
|
|
322
|
+
const initSink = sink();
|
|
323
|
+
await runInit({
|
|
324
|
+
apiKey: BROAD_KEY,
|
|
325
|
+
createInbox: true,
|
|
326
|
+
client: "cursor",
|
|
327
|
+
home,
|
|
328
|
+
baseUrl: BASE,
|
|
329
|
+
fetchImpl: fake.fetch,
|
|
330
|
+
locale: "pt-BR",
|
|
331
|
+
out: initSink.out,
|
|
332
|
+
});
|
|
333
|
+
for (const l of initSink.lines)
|
|
334
|
+
assert.ok(!l.includes(BROAD_KEY), `broad key leaked: ${l}`);
|
|
335
|
+
const docSink = sink();
|
|
336
|
+
const fake2 = new FakeCore().on("GET", (u) => u.pathname.endsWith("/auth/me"), authMePod());
|
|
337
|
+
await runDoctor({ apiKey: BROAD_KEY, baseUrl: BASE, fetchImpl: fake2.fetch, locale: "es-419", out: docSink.out });
|
|
338
|
+
for (const l of docSink.lines)
|
|
339
|
+
assert.ok(!l.includes(BROAD_KEY), `broad key leaked (doctor): ${l}`);
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
cleanup();
|
|
343
|
+
}
|
|
344
|
+
});
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Scope binding — the core of F4's access rule (strategy doc 4.2/4.3, adoption spec line 104):
|
|
2
|
+
// the model NEVER supplies a raw inbox_id that escapes the API key's scope.
|
|
3
|
+
//
|
|
4
|
+
// On boot the server calls GET /v0/auth/me and learns the key's scope:
|
|
5
|
+
// - inbox / domain key -> a single inbox is implied. The inbox_id is DERIVED from the scope and the
|
|
6
|
+
// tools that need one do NOT accept it as an argument at all. The model cannot address any other
|
|
7
|
+
// inbox because the parameter does not exist in the tool schema.
|
|
8
|
+
// - pod / organization key -> the key can see many inboxes. `list_inboxes` enumerates the ones the
|
|
9
|
+
// key may reach; a tool that needs an inbox_id accepts only an id the server has seen in that
|
|
10
|
+
// enumeration, validated against the allowed set before any request leaves the process.
|
|
11
|
+
//
|
|
12
|
+
// The core already fences every route by scope (authz_inbox), so this layer is defense in depth plus
|
|
13
|
+
// an honest tool schema: the key's authority — not the model's argument — decides which inbox is read.
|
|
14
|
+
/** The resolved binding the tool layer consults. `fixedInboxId` set => single-inbox key: tools drop
|
|
15
|
+
* the inbox_id parameter entirely. Otherwise => multi-inbox key: tools require a listed inbox_id,
|
|
16
|
+
* checked via `allows`. `permissions` drives the catalog gate; `podId` lets pod-scoped tools derive
|
|
17
|
+
* the pod from the key's own scope instead of taking it as a free model argument. */
|
|
18
|
+
export class ScopeBinding {
|
|
19
|
+
scopeType;
|
|
20
|
+
fixedInboxId;
|
|
21
|
+
// The key's permission set, verbatim from /auth/me. null = master (full access); an object = scoped.
|
|
22
|
+
permissions;
|
|
23
|
+
// The pod the key is bound to, when it is pod/inbox/domain-scoped. An org (master) key has none, so a
|
|
24
|
+
// pod-scoped tool must then take a validated pod_id argument. Pod-scoped tools NEVER take a free
|
|
25
|
+
// pod_id when this is set — the key's own pod is used, so the model can't address another tenant.
|
|
26
|
+
podId;
|
|
27
|
+
// The set of inbox ids the key has been observed to reach, grown lazily by list_inboxes. A pod/org
|
|
28
|
+
// key can reach ids not yet listed; `allows` therefore also confirms unknown ids against the core
|
|
29
|
+
// (fail-closed) rather than rejecting them for being unseen.
|
|
30
|
+
seen = new Set();
|
|
31
|
+
constructor(scopeType, fixedInboxId, permissions, podId) {
|
|
32
|
+
this.scopeType = scopeType;
|
|
33
|
+
this.fixedInboxId = fixedInboxId;
|
|
34
|
+
this.permissions = permissions;
|
|
35
|
+
this.podId = podId;
|
|
36
|
+
}
|
|
37
|
+
/** True when the key binds exactly one inbox (inbox or domain... no: domain reaches many). Only an
|
|
38
|
+
* inbox-scoped key is truly single. A domain key reaches every inbox of its domain, so it is
|
|
39
|
+
* multi-inbox like a pod key. */
|
|
40
|
+
get isSingleInbox() {
|
|
41
|
+
return this.fixedInboxId !== null;
|
|
42
|
+
}
|
|
43
|
+
/** The catalog gate: does the key hold the permission a tool requires? A master key (permissions ===
|
|
44
|
+
* null) holds everything. A scoped key holds `perm` when its object has it true — with ONE mirror of
|
|
45
|
+
* the core's rule (api.py:1331-1334): message_send satisfies message_reply, so a key that can send
|
|
46
|
+
* can also reply. Tools whose gate is false are never REGISTERED (hidden, not registered-then-403). */
|
|
47
|
+
gateAllows(requiredPerm) {
|
|
48
|
+
if (this.permissions === null)
|
|
49
|
+
return true; // master key
|
|
50
|
+
if (requiredPerm === null)
|
|
51
|
+
return true; // a tool with no gated route (none scope)
|
|
52
|
+
if (this.permissions[requiredPerm] === true)
|
|
53
|
+
return true;
|
|
54
|
+
if (requiredPerm === "message_reply" && this.permissions["message_send"] === true)
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/** Call /v0/auth/me and build the binding. An inbox key yields a fixed inbox_id; every other scope
|
|
59
|
+
* is multi-inbox and the inbox_id must be supplied-and-validated per call. Captures the key's
|
|
60
|
+
* permissions (catalog gate) and pod_id (pod-scoped tools derive the pod from the scope). */
|
|
61
|
+
static async resolve(client) {
|
|
62
|
+
const me = await client.get("/auth/me");
|
|
63
|
+
const permissions = me.permissions ?? null;
|
|
64
|
+
const podId = me.pod_id ?? null;
|
|
65
|
+
if (me.scope_type === "inbox" && me.inbox_id) {
|
|
66
|
+
return new ScopeBinding("inbox", me.inbox_id, permissions, podId);
|
|
67
|
+
}
|
|
68
|
+
// domain / pod / organization: no single implied inbox.
|
|
69
|
+
return new ScopeBinding(me.scope_type, null, permissions, podId);
|
|
70
|
+
}
|
|
71
|
+
/** Record inbox ids returned by list_inboxes so later calls can validate against the enumeration. */
|
|
72
|
+
remember(ids) {
|
|
73
|
+
for (const id of ids)
|
|
74
|
+
this.seen.add(id);
|
|
75
|
+
}
|
|
76
|
+
/** Resolve the inbox_id a tool will act on. For a single-inbox key the model's argument (if any) is
|
|
77
|
+
* IGNORED and the fixed id is used — the model can never widen past its scope. For a multi-inbox
|
|
78
|
+
* key the argument is required and must be an id the key can reach: known from a prior list, or
|
|
79
|
+
* confirmed against the core (which fences by scope and 404s an inbox outside it). Returns the id,
|
|
80
|
+
* or throws a stable `not_found` (never distinguishing "unknown" from "forbidden"). */
|
|
81
|
+
async resolveInboxId(client, requested) {
|
|
82
|
+
if (this.fixedInboxId !== null)
|
|
83
|
+
return this.fixedInboxId;
|
|
84
|
+
const id = (requested ?? "").trim();
|
|
85
|
+
if (!id)
|
|
86
|
+
throw new ScopeError("inbox_required");
|
|
87
|
+
if (this.seen.has(id))
|
|
88
|
+
return id;
|
|
89
|
+
// Not seen yet. Confirm it against the core: get_inbox 404s (via not_found) for any inbox the key
|
|
90
|
+
// may not reach, so a hostile id never turns into a cross-tenant read. On success, remember it.
|
|
91
|
+
await client.get(`/inboxes/${encodeURIComponent(id)}`); // throws CoreError('not_found') if out of scope
|
|
92
|
+
this.seen.add(id);
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
/** Resolve the pod_id a pod-scoped tool acts on. Same discipline as resolveInboxId: when the key is
|
|
96
|
+
* itself pod/inbox/domain-scoped the model's argument is IGNORED and the key's own pod is used — it
|
|
97
|
+
* cannot address another tenant. Only a master (org) key, which binds no single pod, must supply a
|
|
98
|
+
* pod_id; it is then validated against the core (get_pod 404s -> not_found for an unknown pod) so a
|
|
99
|
+
* forged id never turns into a cross-tenant action. */
|
|
100
|
+
async resolvePodId(client, requested) {
|
|
101
|
+
if (this.podId !== null)
|
|
102
|
+
return this.podId;
|
|
103
|
+
const id = (requested ?? "").trim();
|
|
104
|
+
if (!id)
|
|
105
|
+
throw new ScopeError("pod_required");
|
|
106
|
+
await client.get(`/pods/${encodeURIComponent(id)}`); // throws CoreError('not_found') if out of scope
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** A scope-layer failure the tool layer maps to an i18n message for a human. Locale-free, key-free. */
|
|
111
|
+
export class ScopeError extends Error {
|
|
112
|
+
code;
|
|
113
|
+
constructor(code) {
|
|
114
|
+
super(code);
|
|
115
|
+
this.name = "ScopeError";
|
|
116
|
+
this.code = code;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
5
|
+
import { CoreClient, CoreError } from "./client.js";
|
|
6
|
+
import { ScopeBinding } from "./scope.js";
|
|
7
|
+
import { specByName, callTool } from "./tools.js";
|
|
8
|
+
import { buildServer } from "./server.js";
|
|
9
|
+
import { FakeCore, authMeInbox, authMePod } from "./testutil.js";
|
|
10
|
+
const KEY = "pk_live_secret_do_not_log_abcdef";
|
|
11
|
+
async function ctxFor(fake) {
|
|
12
|
+
const client = new CoreClient({ apiKey: KEY, baseUrl: "https://core.test/v0", fetchImpl: fake.fetch });
|
|
13
|
+
const scope = await ScopeBinding.resolve(client);
|
|
14
|
+
return { client, scope };
|
|
15
|
+
}
|
|
16
|
+
test("the API key rides ONLY in the Authorization header — never in a URL", async () => {
|
|
17
|
+
const fake = new FakeCore()
|
|
18
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMeInbox("inb_x"))
|
|
19
|
+
.on("GET", () => true, { json: { ok: true } });
|
|
20
|
+
const ctx = await ctxFor(fake);
|
|
21
|
+
await callTool(specByName("list_messages", ctx.scope), {}, ctx);
|
|
22
|
+
await callTool(specByName("get_message", ctx.scope), { message_id: "m1" }, ctx);
|
|
23
|
+
assert.ok(fake.requests.length >= 2);
|
|
24
|
+
for (const r of fake.requests) {
|
|
25
|
+
assert.ok(!r.url.includes(KEY), `key leaked into URL: ${r.url}`);
|
|
26
|
+
assert.equal(r.headers["authorization"], `Bearer ${KEY}`);
|
|
27
|
+
// No other header carries it.
|
|
28
|
+
for (const [k, v] of Object.entries(r.headers)) {
|
|
29
|
+
if (k !== "authorization")
|
|
30
|
+
assert.ok(!v.includes(KEY), `key leaked into header ${k}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
test("a transport error message never contains the key or the URL", async () => {
|
|
35
|
+
const fetchImpl = async () => {
|
|
36
|
+
throw new TypeError("fetch failed: connect ECONNREFUSED https://core.test/v0?api_key=" + KEY);
|
|
37
|
+
};
|
|
38
|
+
const client = new CoreClient({ apiKey: KEY, baseUrl: "https://core.test/v0", fetchImpl });
|
|
39
|
+
await assert.rejects(() => client.get("/inboxes"), (e) => {
|
|
40
|
+
assert.ok(e instanceof CoreError);
|
|
41
|
+
assert.ok(!e.message.includes(KEY), "key must not appear in the error");
|
|
42
|
+
assert.ok(!e.message.includes("core.test"), "URL must not appear in the error");
|
|
43
|
+
assert.equal(e.code, "network");
|
|
44
|
+
return true;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
test("auth failure does NOT reveal whether an inbox exists (403 and 404 collapse to not_found)", async () => {
|
|
48
|
+
const forbidden = new FakeCore()
|
|
49
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMePod())
|
|
50
|
+
.on("GET", (u) => /\/inboxes\/inb_q$/.test(u.pathname), { status: 403, json: { code: "forbidden" } });
|
|
51
|
+
const missing = new FakeCore()
|
|
52
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMePod())
|
|
53
|
+
.on("GET", (u) => /\/inboxes\/inb_q$/.test(u.pathname), { status: 404, json: { code: "not_found" } });
|
|
54
|
+
const errOf = async (fake) => {
|
|
55
|
+
const ctx = await ctxFor(fake);
|
|
56
|
+
try {
|
|
57
|
+
await callTool(specByName("get_thread", ctx.scope), { inbox_id: "inb_q", thread_id: "t1" }, ctx);
|
|
58
|
+
return "no-error";
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
return e.code ?? "unknown";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// A key denied the inbox (403) and a key facing a missing inbox (404) get the IDENTICAL answer, so
|
|
65
|
+
// the model can never use the error to enumerate which inboxes exist.
|
|
66
|
+
assert.equal(await errOf(forbidden), "not_found");
|
|
67
|
+
assert.equal(await errOf(missing), "not_found");
|
|
68
|
+
});
|
|
69
|
+
test("end-to-end over MCP: list tools and call one through the real server adapter", async () => {
|
|
70
|
+
const fake = new FakeCore()
|
|
71
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMeInbox("inb_e2e"))
|
|
72
|
+
.on("GET", (u) => /\/inboxes\/inb_e2e\/threads$/.test(u.pathname), {
|
|
73
|
+
json: { threads: [{ tid: "thr_1" }] },
|
|
74
|
+
});
|
|
75
|
+
const core = new CoreClient({ apiKey: KEY, baseUrl: "https://core.test/v0", fetchImpl: fake.fetch });
|
|
76
|
+
const server = await buildServer(core);
|
|
77
|
+
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
|
|
78
|
+
const client = new Client({ name: "test-client", version: "0" });
|
|
79
|
+
await Promise.all([server.connect(serverT), client.connect(clientT)]);
|
|
80
|
+
const listed = await client.listTools();
|
|
81
|
+
assert.ok(listed.tools.some((t) => t.name === "list_threads"));
|
|
82
|
+
// Single-inbox key: no tool exposes inbox_id.
|
|
83
|
+
for (const t of listed.tools) {
|
|
84
|
+
const props = (t.inputSchema?.properties ?? {});
|
|
85
|
+
assert.ok(!("inbox_id" in props));
|
|
86
|
+
}
|
|
87
|
+
const res = await client.callTool({ name: "list_threads", arguments: {} });
|
|
88
|
+
assert.equal(res.isError ?? false, false);
|
|
89
|
+
const content = res.content;
|
|
90
|
+
const payload = JSON.parse(content[0].text);
|
|
91
|
+
assert.equal(payload.threads[0].tid, "thr_1");
|
|
92
|
+
await client.close();
|
|
93
|
+
await server.close();
|
|
94
|
+
});
|
|
95
|
+
test("end-to-end: a core error surfaces as an isError tool result with a stable code (no leak)", async () => {
|
|
96
|
+
const fake = new FakeCore()
|
|
97
|
+
.on("GET", (u) => u.pathname.endsWith("/auth/me"), authMeInbox("inb_e2e"))
|
|
98
|
+
.on("GET", () => true, { status: 404, json: { code: "not_found" } });
|
|
99
|
+
const core = new CoreClient({ apiKey: KEY, baseUrl: "https://core.test/v0", fetchImpl: fake.fetch });
|
|
100
|
+
const server = await buildServer(core);
|
|
101
|
+
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
|
|
102
|
+
const client = new Client({ name: "test-client", version: "0" });
|
|
103
|
+
await Promise.all([server.connect(serverT), client.connect(clientT)]);
|
|
104
|
+
const res = await client.callTool({ name: "get_message", arguments: { message_id: "m_x" } });
|
|
105
|
+
assert.equal(res.isError, true);
|
|
106
|
+
const content = res.content;
|
|
107
|
+
assert.equal(content[0].text, "error: not_found");
|
|
108
|
+
assert.ok(!content[0].text.includes(KEY));
|
|
109
|
+
await client.close();
|
|
110
|
+
await server.close();
|
|
111
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The MCP server — a thin stdio adapter over the tool catalog. It registers the tools the scope
|
|
2
|
+
// allows, dispatches CallTool to the matching handler, and translates failures into a tool error the
|
|
3
|
+
// model can read. It never reimplements core logic; every call is one REST request.
|
|
4
|
+
//
|
|
5
|
+
// Log hygiene (AGENTS.md §5, audited by pombus-sec): the stdio stream IS the MCP channel, so nothing
|
|
6
|
+
// but protocol frames may touch stdout. Diagnostics go to stderr, and NOTHING we print ever includes
|
|
7
|
+
// the API key — the key lives only in the client's Authorization header, never in an argument, a URL,
|
|
8
|
+
// or an error message surfaced here.
|
|
9
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import { CoreClient, CoreError, readApiKey } from "./client.js";
|
|
13
|
+
import { ScopeBinding, ScopeError } from "./scope.js";
|
|
14
|
+
import { specByName, toolDescriptors, callTool, ToolArgError } from "./tools.js";
|
|
15
|
+
const SERVER_NAME = "pombus";
|
|
16
|
+
const SERVER_VERSION = "0.1.0";
|
|
17
|
+
/** Build and connect the MCP server over stdio. Fails fast (before connecting) when the key is absent,
|
|
18
|
+
* so a misconfigured client sees a clear stderr line instead of a server that answers every tool with
|
|
19
|
+
* an auth error. */
|
|
20
|
+
export async function runMcpServer() {
|
|
21
|
+
const { humanMessage, humanCodeForBoot } = await import("./i18n.js");
|
|
22
|
+
const apiKey = readApiKey();
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
// Human-facing, localized, to stderr — never stdout (would corrupt the MCP stream).
|
|
25
|
+
process.stderr.write(humanMessage("no_api_key") + "\n");
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const client = new CoreClient({ apiKey });
|
|
30
|
+
let server;
|
|
31
|
+
try {
|
|
32
|
+
// buildServer resolves the key's scope (one /v0/auth/me call). A human runs the server, so a boot
|
|
33
|
+
// failure here is human-facing: localize the REAL cause — a rejected key (auth_failed) or an
|
|
34
|
+
// unreachable core (network) — instead of a generic line. CoreError already carries only a safe,
|
|
35
|
+
// key-free code.
|
|
36
|
+
server = await buildServer(client);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
const code = e instanceof CoreError ? humanCodeForBoot(e.code) : "unknown";
|
|
40
|
+
process.stderr.write(humanMessage(code) + "\n");
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const transport = new StdioServerTransport();
|
|
45
|
+
await server.connect(transport);
|
|
46
|
+
}
|
|
47
|
+
/** Build the Server bound to a given core client. Exposed so tests drive it with a fake core and
|
|
48
|
+
* without a real stdio transport. Resolves the key's scope up front (one /v0/auth/me call) so the
|
|
49
|
+
* tool schemas can omit inbox_id for a single-inbox key. */
|
|
50
|
+
export async function buildServer(client) {
|
|
51
|
+
const scope = await ScopeBinding.resolve(client);
|
|
52
|
+
const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
53
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
54
|
+
tools: toolDescriptors(scope),
|
|
55
|
+
}));
|
|
56
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
57
|
+
// specByName honours the gate: a tool the key may not use is "unknown" here, exactly as it is
|
|
58
|
+
// absent from ListTools — the model can neither see nor call a tool outside its permissions.
|
|
59
|
+
const spec = specByName(req.params.name, scope);
|
|
60
|
+
if (!spec)
|
|
61
|
+
return toolError(`unknown tool: ${req.params.name}`);
|
|
62
|
+
const args = (req.params.arguments ?? {});
|
|
63
|
+
try {
|
|
64
|
+
const value = await callTool(spec, args, { client, scope });
|
|
65
|
+
return { content: [{ type: "text", text: toText(value) }] };
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return toolError(modelError(e));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return server;
|
|
72
|
+
}
|
|
73
|
+
/** Serialize a handler result for the model. undefined (e.g. a 204) becomes an explicit ok marker. */
|
|
74
|
+
function toText(value) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return JSON.stringify({ ok: true });
|
|
77
|
+
return JSON.stringify(value);
|
|
78
|
+
}
|
|
79
|
+
/** A CallTool result flagged as an error (isError), carrying model-facing en-US text. */
|
|
80
|
+
function toolError(text) {
|
|
81
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
82
|
+
}
|
|
83
|
+
/** Map a thrown error to a stable, locale-free string for the model. Never leaks the key or a URL:
|
|
84
|
+
* CoreError/ScopeError/ToolArgError already carry only a safe code; anything else collapses to a
|
|
85
|
+
* generic message so an unexpected throw can't smuggle a secret into the tool output. */
|
|
86
|
+
function modelError(e) {
|
|
87
|
+
if (e instanceof CoreError)
|
|
88
|
+
return `error: ${e.code}`;
|
|
89
|
+
if (e instanceof ScopeError)
|
|
90
|
+
return `error: ${e.code}`;
|
|
91
|
+
if (e instanceof ToolArgError)
|
|
92
|
+
return `error: ${e.code}`;
|
|
93
|
+
return "error: internal";
|
|
94
|
+
}
|