Public: anyone can read this; the crew can change it.

One table-driven test: every MCP tool that writes refuses a read-only token #11501

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11493-one-table-driven-test-every-mcp-tool-tha → main 1 commit fork

  • Merged. scout/11493-one-table-driven-test-every-mcp-tool-tha was merged into main as 84bb546.

Review

Sign in to review this change.
1 file changed +186 −0
test/mcp-ro-refuses-writes.test.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * The read-only/read-write line on /mcp, held over every tool at once
3 * (#11493), not by each tool's own test.
4 *
5 * One forge boots with an admin, a repository, and an `ro` token for the
6 * admin (so the admin-only tools are listed too). `tools/list` is walked as
7 * that token, and every listed tool is called:
8 *
9 * - without `readOnlyHint: true` (it writes), with arguments that reach its
10 * write path as far as `plansOnly` reads them — `dryRun: false`, an
11 * `action` that is not a list, every `readsWithout` key given — and the
12 * answer must be the plain refusal, with no row written anywhere;
13 * - with `readOnlyHint: true`, with no arguments and again with the repo,
14 * and no table may gain or lose a row.
15 *
16 * A new tool that writes without saying so, or one whose `readsWhen` the
17 * write arguments here do not get past, fails a test that names it. The
18 * admin-scope tools are not listed to an `ro` token at all; the last test
19 * holds that each is refused by name.
20 */
21 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22 import { mkdtemp, rm } from "node:fs/promises";
23 import { tmpdir } from "node:os";
24 import { join } from "node:path";
25 import { createApp, type App } from "../src/app.ts";
26 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
27 import { createToken } from "../src/core/tokens.ts";
28 import { createUser } from "../src/core/users.ts";
29 import { plansOnly } from "../src/http/mcp/shared.ts";
30 import { TOOLS } from "../src/http/mcp/tools.ts";
31 import { serve, type Servers } from "../src/http/server.ts";
32 import { listAt } from "./mcp-list.ts";
33
34 const REFUSAL = "forbidden: this token may read, not act";
35 const REPO = "alice/game";
36 /**
37 * The forge's own traffic counts: every request adds to them, flushed by a
38 * timer about a second later (`core/route_timing.ts`, `core/usage_count.ts`),
39 * so whichever call is in flight then would be blamed for them.
40 */
41 const TRAFFIC = new Set(["route_timing", "usage_count"]);
42
43 /** Arguments that take a writing tool past every `plansOnly` door it has. */
44 function writeArgs(name: string): Record<string, unknown> {
45 const tool = TOOLS.find((t) => t.name === name);
46 const args: Record<string, unknown> = { repo: REPO, dryRun: false, apply: true, action: "write" };
47 for (const k of tool?.readsWithout === undefined ? [] : [tool.readsWithout].flat()) args[k] ??= "x";
48 return args;
49 }
50
51 describe("every MCP tool that writes refuses a read-only token", () => {
52 let dataDir: string;
53 let app: App;
54 let servers: Servers;
55 let ro: string;
56 let listed: { name: string; annotations?: { readOnlyHint?: boolean } }[] = [];
57
58 /** Rows in every table, by table: what a write would change. */
59 const rows = (): Record<string, number> => {
60 const tables = app.db
61 .query<{ name: string }, []>(
62 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
63 )
64 .all();
65 const out: Record<string, number> = {};
66 for (const { name } of tables) {
67 if (TRAFFIC.has(name)) continue;
68 try {
69 out[name] = app.db.query<{ n: number }, []>(`SELECT count(*) AS n FROM "${name.replaceAll('"', '""')}"`).get()!.n;
70 } catch {
71 // A virtual table's shadow may not count on its own; its parent does.
72 }
73 }
74 return out;
75 };
76
77 /** The tables whose row count moved between two counts, named. */
78 const moved = (a: Record<string, number>, b: Record<string, number>): string[] =>
79 [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((t) => a[t] !== b[t]).map((t) => `${t} ${a[t] ?? 0}→${b[t] ?? 0}`);
80
81 const call = async (name: string, args: unknown): Promise<{ isError: boolean; text: string }> => {
82 const res = await fetch(`${servers.url}/mcp`, {
83 method: "POST",
84 headers: {
85 "content-type": "application/json",
86 accept: "application/json, text/event-stream",
87 authorization: `Bearer ${ro}`,
88 },
89 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
90 });
91 const body = (await res.json()) as any;
92 if (body.error !== undefined) return { isError: true, text: String(body.error.message) };
93 return { isError: body.result.isError === true, text: String(body.result.content?.[0]?.text ?? "") };
94 };
95
96 beforeAll(async () => {
97 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-ro-"));
98 app = createApp({ dataDir, port: 0 });
99 servers = await serve(app);
100 const made = await createUser(app.db, {
101 origin: "seed",
102 trusted: true,
103 username: "alice",
104 email: "[email protected]",
105 password: "a-good-passphrase",
106 adminIfFirst: true,
107 });
108 if (!made.ok) throw new Error(made.error);
109 const alice = made.value;
110 const repo = await createRepo(app, alice, { name: "game", visibility: "public" });
111 if (!repo.ok) throw new Error(String(repo.error));
112 await commitToBranch(app, alice, repo.value, { message: "seed", files: [{ path: "README.md", content: "# game\n" }] });
113 ro = createToken(app.db, alice.id, "ro", "ro").token;
114 listed = (await listAt(servers.url, ro)).result.tools;
115 }, 30_000);
116
117 afterAll(async () => {
118 servers.stop();
119 app.close();
120 await rm(dataDir, { recursive: true, force: true });
121 });
122
123 test("tools/list names the tools, and the writing ones are most of them", () => {
124 expect(listed.length).toBeGreaterThan(20);
125 expect(listed.filter((t) => t.annotations?.readOnlyHint !== true).length).toBeGreaterThan(10);
126 });
127
128 test("the write arguments here get past every tool's own read door", () => {
129 // A tool whose `readsWhen` reads these as a read would be called here
130 // and never reach the floor: name it, so its row in writeArgs is added.
131 const stuck = TOOLS.filter((t) => t.writes && plansOnly(t, writeArgs(t.name))).map((t) => t.name);
132 expect(stuck).toEqual([]);
133 });
134
135 test("every tool without readOnlyHint refuses the ro token and writes nothing", async () => {
136 const open: string[] = [];
137 for (const t of listed) {
138 if (t.annotations?.readOnlyHint === true) continue;
139 // `report` and `start` are the token-free tools: a stranger may call
140 // them, so an `ro` token is no less than no token; fuzz-mcp holds them.
141 if (t.name === "report" || t.name === "start") continue;
142 const before = rows();
143 const got = await call(t.name, writeArgs(t.name));
144 const after = rows();
145 if (!got.isError || got.text !== REFUSAL) open.push(`${t.name}: ${got.text.slice(0, 120)}`);
146 const changed = moved(before, after);
147 if (changed.length > 0) open.push(`${t.name} wrote: ${changed.join(", ")}`);
148 }
149 expect(open).toEqual([]);
150 }, 10_000);
151
152 test("every readOnlyHint tool leaves every table's row count as it was", async () => {
153 const wrote: string[] = [];
154 for (const t of listed) {
155 if (t.annotations?.readOnlyHint !== true) continue;
156 for (const args of [{}, { repo: REPO }]) {
157 const before = rows();
158 await call(t.name, args);
159 const changed = moved(before, rows());
160 if (changed.length > 0) wrote.push(`${t.name}(${JSON.stringify(args)}) wrote: ${changed.join(", ")}`);
161 }
162 }
163 expect(wrote).toEqual([]);
164 }, 10_000);
165
166 test("a readOnlyHint is said only of a tool that does not write", () => {
167 const said = new Set(listed.filter((t) => t.annotations?.readOnlyHint === true).map((t) => t.name));
168 expect(TOOLS.filter((t) => t.writes && said.has(t.name)).map((t) => t.name)).toEqual([]);
169 });
170
171 test("the admin-scope tools are not listed to an ro token, and are refused by name", async () => {
172 const names = new Set(listed.map((t) => t.name));
173 const hidden = TOOLS.filter((t) => t.needsAdminScope === true);
174 expect(hidden.filter((t) => names.has(t.name)).map((t) => t.name)).toEqual([]);
175 for (const t of hidden) {
176 const before = rows();
177 const got = await call(t.name, writeArgs(t.name));
178 expect({ name: t.name, isError: got.isError, text: got.text }).toEqual({
179 name: t.name,
180 isError: true,
181 text: `no tool named ${t.name}`,
182 });
183 expect(moved(before, rows())).toEqual([]);
184 }
185 });
186 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs