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

A test calls every MCP tool marked as a write with a read-only token and finds each refused before it writes #5883

Merged opened this

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

Merged

scout/forge:scout/5876-a-test-calls-every-mcp-tool-marked-as-a → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/5876-a-test-calls-every-mcp-tool-marked-as-a was merged into main as 39bc622.

Review

Sign in to review this change.
2 files changed +168 −5
src/http/mcp.ts +17 −5
@@ -354,6 +354,22 @@ const visible = (tool: Tool, ctx: Ctx, user: User): boolean =>
354354 (tool.adminOnly !== true || user.isAdmin) &&
355355 (tool.needsAdminScope !== true || scopeAllows(ctx.auth.scope ?? "ro", "admin"));
356356
357 /** Whether this call only plans or reads, so a read-only token may make it. */
358 const plansOnly = (tool: Tool, args: Args): boolean =>
359 (tool.dryByDefault === true && args["dryRun"] !== false) ||
360 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined) ||
361 tool.readsWhen?.(args) === true;
362
363 /** What each tool declares about itself, for the test that walks them all. */
364 export const toolRegistry = () =>
365 TOOLS.map((t) => ({
366 name: t.name,
367 writes: t.writes,
368 needsAdminScope: t.needsAdminScope === true,
369 readsWithout: t.readsWithout,
370 plansOnly: (args: Args) => plansOnly(t, args),
371 }));
372
357373 const CHIP_ARGS = {
358374 hide_kinds: {
359375 type: "array",
@@ -5108,11 +5124,7 @@ function rpcOne(
51085124 typeof params["arguments"] === "object" && params["arguments"] !== null
51095125 ? (params["arguments"] as Args)
51105126 : {};
5111 const plansOnly =
5112 (tool.dryByDefault === true && args["dryRun"] !== false) ||
5113 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined) ||
5114 tool.readsWhen?.(args) === true;
5115 if (tool.writes && !plansOnly && !scopeAllows(ctx.auth.scope ?? "ro", "rw"))
5127 if (tool.writes && !plansOnly(tool, args) && !scopeAllows(ctx.auth.scope ?? "ro", "rw"))
51165128 return reply({
51175129 content: [
51185130 { type: "text", text: "forbidden: this token may read, not act" },
test/mcp-write-scope.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * Every MCP tool that writes, called with a read-only token, is refused
3 * before it writes; every tool that needs an admin token is refused to an
4 * `rw` one. The event log and every table's row count stay as they were.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { toolRegistry, toolNames } from "../src/http/mcp.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16
17 /** Tools this test cannot call as a write, by name, and why. */
18 const SKIPPED: Record<string, string> = {};
19
20 /** Valid arguments for each admin-scope tool: were its gate gone, the call would act. */
21 const ADMIN_ARGS: Record<string, Record<string, unknown>> = {
22 repo_settings: { repo: "alice/game", visibility: "public" },
23 mirror_set: { action: "forget" },
24 org_teams: { org: "acme", action: "create", team: "core", permission: "read" },
25 agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" },
26 subscription_budget: { id: "u1", usd: 5 },
27 prompts: { repo: "alice/game", action: "remove", id: "p1" },
28 telemetry_grant: { agent: "robo", on: true },
29 hooks: { repo: "alice/game", action: "add", url: "https://example.com/h", secret: "0123456789abcdef0123" },
30 };
31
32 /** What `visible()` answers for a tool the token's scope does not open. */
33 const notListed = (body: any): boolean =>
34 body.error?.code === -32602 && String(body.error.message).startsWith("no tool named");
35
36 describe("a token that may only read cannot write through any tool", () => {
37 let dataDir: string;
38 let app: App;
39 let servers: Servers;
40 let ro = "";
41 let rw = "";
42 let id = 1;
43
44 beforeAll(async () => {
45 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-scope-"));
46 app = createApp({ dataDir, port: 0 });
47 servers = await serve(app);
48 const made = await createUser(app.db, {
49 origin: "seed",
50 trusted: true,
51 username: "alice",
52 email: "[email protected]",
53 password: "a-good-passphrase",
54 adminIfFirst: true,
55 });
56 if (!made.ok) throw new Error(made.error);
57 const repo = await createRepo(app, made.value, { name: "game", visibility: "private" });
58 if (!repo.ok) throw new Error(String(repo.error));
59 ro = createToken(app.db, made.value.id, "ro", "ro").token;
60 rw = createToken(app.db, made.value.id, "rw", "rw").token;
61 }, 30_000);
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 const rpc = async (token: string, name: string, args: Record<string, unknown>) => {
outdated

action: "x" and readsWithout = "x" are invalid on purpose. That is fine for the ro branch, which checks the exact scope text. It is not fine anywhere a generic error counts as a refusal. The issue asks for minimal valid arguments, so with valid ones a tool that wrongly ran would visibly succeed or write.

69 const res = await fetch(`${servers.url}/mcp`, {
70 method: "POST",
71 headers: {
72 "content-type": "application/json",
73 accept: "application/json, text/event-stream",
74 authorization: `Bearer ${token}`,
75 },
76 body: JSON.stringify({
77 jsonrpc: "2.0",
78 id: id++,
79 method: "tools/call",
80 params: { name, arguments: args },
81 }),
82 });
83 return (await res.json()) as any;
84 };
85
86 /** Minimal arguments that make the call a real write, not a plan or a list. */
87 const writing = (t: ReturnType<typeof toolRegistry>[number]) => {
88 if (t.name in ADMIN_ARGS) return ADMIN_ARGS[t.name]!;
outdated

Non-blocking. For ordinary write tools these are placeholder arguments. The exact forbidden text check is what proves the gate. The row-count and event check after the walk proves little for these calls: if the gate were gone, most of them would be stopped by argument validation and still write nothing. The issue asked for minimal valid arguments. A follow-up could give a few high-value tools (say, secrets, mint_invite) real arguments, so the 'nothing changed' check works without relying on the error text.

89 const args: Record<string, unknown> = { dryRun: false, action: "x" };
90 if (t.readsWithout !== undefined) args[t.readsWithout] = "x";
91 return args;
92 };
93
94 const snapshot = () => {
95 const tables = app.db
96 .query<{ name: string }, []>(
97 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
98 )
99 .all()
100 .map((r) => r.name);
101 return Object.fromEntries(
102 tables.map((n) => [
103 n,
104 app.db.query<{ c: number }, []>(`SELECT count(*) AS c FROM "${n}"`).get()!.c,
105 ]),
106 );
outdated

Same flaw for ro → admin tools: body.result?.isError === true also matches a validation error from a tool that ran. Accept only the no tool named refusal.

107 };
108
109 test("the list of skipped tools names only tools that exist", () => {
110 const names = new Set(toolNames());
111 for (const name of Object.keys(SKIPPED)) expect(names.has(name)).toBe(true);
112 });
113
114 test("each write is refused for a read-only token, and nothing changes", async () => {
115 const writes = toolRegistry().filter((t) => t.writes && !(t.name in SKIPPED));
116 expect(writes.length).toBeGreaterThan(50);
117 const before = snapshot();
118 const events = app.db.query("SELECT * FROM event ORDER BY rowid").all();
119 const unrefused: string[] = [];
120 for (const t of writes) {
121 const args = writing(t);
122 expect(t.plansOnly(args)).toBe(false);
123 const body = await rpc(ro, t.name, args);
outdated

High: any isError counts as refused here. With the admin-scope check removed from visible(), every admin tool fails on the placeholder args (not_found / invalid), and this test still passes. Assert body.error?.code === -32602 && String(body.error.message).startsWith("no tool named").

124 const text: string | undefined = body.result?.content?.[0]?.text;
125 const refused = t.needsAdminScope
126 ? notListed(body)
127 : body.result?.isError === true && text === "forbidden: this token may read, not act";
128 if (!refused) unrefused.push(t.name);
129 }
130 expect(unrefused).toEqual([]);
131 expect(snapshot()).toEqual(before);
132 expect(app.db.query("SELECT * FROM event ORDER BY rowid").all()).toEqual(events);
133 }, 8_000);
134
135 test("each admin-scope tool is refused for an rw token, and nothing changes", async () => {
136 const admin = toolRegistry().filter((t) => t.needsAdminScope);
137 expect(admin.length).toBeGreaterThan(0);
138 // A new admin-scope tool must be given valid arguments here.
139 expect(admin.map((t) => t.name).sort()).toEqual(Object.keys(ADMIN_ARGS).sort());
140 const before = snapshot();
141 const events = app.db.query("SELECT * FROM event ORDER BY rowid").all();
142 const answered: string[] = [];
143 for (const t of admin) {
144 const body = await rpc(rw, t.name, writing(t));
145 if (!notListed(body)) answered.push(t.name);
146 }
147 expect(answered).toEqual([]);
148 expect(snapshot()).toEqual(before);
149 expect(app.db.query("SELECT * FROM event ORDER BY rowid").all()).toEqual(events);
150 }, 8_000);
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs