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

MCP `mirror_set`: set or forget the off-box mirror of snapshots, with the same rules as the Backups page #5457

Merged opened this

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

Merged

scout/forge:scout/5442-mcp-mirror-set-set-or-forget-the-off-box → main 2 commits fork 6 threads · 6 unresolved

  • Merged. scout/5442-mcp-mirror-set-set-or-forget-the-off-box was merged into main as e8ba7d1.

Review

Sign in to review this change.
3 files changed +183 −0
docs/MCP.md +1
@@ -42,6 +42,7 @@ for a tunnel and a forge that sleeps.
4242 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | no |
4343 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
4444 | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes |
45 | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes |
4546 | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
4647 | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | |
4748 | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | |
src/http/mcp.ts +45
@@ -38,6 +38,8 @@ import { getOrg, listTeams, type Permission } from "../core/orgs.ts";
3838 import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
3939 import { readScorecards } from "../actions/scorecard.ts";
4040 import { createAgent, deleteAgent, updateAgent } from "../actions/crew.ts";
41 import { forgetMirror, saveMirror } from "../actions/mirror.ts";
42 import { snapshotsOf } from "../snapshot/service.ts";
4143 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
4244 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts";
4345 import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
@@ -1337,6 +1339,49 @@ const TOOLS: readonly Tool[] = [
13371339 return bad("invalid", "action is list, set or remove");
13381340 },
13391341 },
1342 {
1343 name: "mirror_set",
1344 description: "Admin: set or forget the off-box snapshot mirror.",
1345 inputSchema: {
1346 type: "object",
1347 properties: {
1348 action: { type: "string" },
1349 endpoint: { type: "string" },
1350 bucket: { type: "string" },
1351 region: { type: "string" },
1352 accessKey: { type: "string" },
1353 secretKey: { type: "string" },
1354 },
1355 },
1356 writes: true,

High: add needsAdminScope: true here. This writes forge-level sealed secrets, and secrets requires an admin-scope token for exactly that write. As written, an admin's rw token can redirect the snapshot mirror, and with it all forge data, to any bucket.

1357 adminOnly: true,
1358 needsAdminScope: true,
1359 run(ctx, user, args) {
1360 const place = (): string | null => snapshotsOf(ctx.app)?.mirror?.where ?? null;
1361 if (args["action"] === "forget") {
1362 const gone = forgetMirror(ctx.app, user);
1363 if (!gone.ok) return bad("not_found", "not found");
1364 return good({ place: place(), says: "The sealed mirror is cleared." });
1365 }
1366 if (args["action"] !== "set") return bad("invalid", "action is set or forget");
1367 const field = (k: string): string | undefined => {
1368 const v = args[k];
1369 return v === undefined ? "" : typeof v === "string" ? v.trim() : undefined;
1370 };
1371 const input = {
1372 endpoint: field("endpoint"),
1373 bucket: field("bucket"),
1374 region: field("region"),
1375 accessKey: field("accessKey"),
1376 secretKey: field("secretKey"),
1377 };
1378 if (Object.values(input).some((v) => v === undefined))
1379 return bad("invalid", "the fields are strings");
1380 const saved = saveMirror(ctx.app, user, input as Record<keyof typeof input, string>);
1381 if (!saved.ok) return bad(saved.error === "not_found" ? "not_found" : "invalid", saved.error);
1382 return good({ place: place(), says: saved.value });
1383 },
1384 },
13401385 {
13411386 name: "issue_teams",
13421387 description:

Right bar: same as secrets, and the page's readForm(ctx, "admin"). Side effect: test/mcp-size.test.ts lists tools with an rw token, so it no longer measures this tool. Neither it nor any other admin-scope tool counts against the 37,000-byte headroom. Non-blocking, but an admin-scope pass in that test would keep the ceiling honest.

test/mcp-mirror-set.test.ts added +137
@@ -0,0 +1,137 @@
1 /** The `mirror_set` tool is the Backups page's mirror form over MCP: admins only, and no answer carries a key. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { resolveMirror } from "../src/snapshot/mirror.ts";
11 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
12 import { dirStore } from "../src/snapshot/store.ts";
13 import { promote } from "./helpers/members.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 describe("MCP mirror_set", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let svc: SnapshotsHandle;
23 let admin: string;
24 let other: string;
25 let adminRw: string;
26 let cookie = "";
27 let id = 1;
28 const SECRET = "s3cr3t-key-that-must-not-leak";
29 // The forge's own listener stands in for the bucket: it answers at once, and says no.
30 let FIELDS: Record<string, string>;
31
32 const tool = async (token: string, args: unknown) => {
33 const res = await fetch(`${servers.url}/mcp`, {
34 method: "POST",
35 headers: {
36 "content-type": "application/json",
37 accept: "application/json, text/event-stream",
38 authorization: `Bearer ${token}`,
39 },
40 body: JSON.stringify({
41 jsonrpc: "2.0",
42 id: id++,
43 method: "tools/call",
44 params: { name: "mirror_set", arguments: args },
45 }),
46 });
47 const body = (await res.json()) as any;
48 if (body.error) return { text: JSON.stringify(body.error), isError: true };
49 return { text: body.result.content[0].text as string, isError: body.result.isError === true };
50 };
51 const page = async () =>
52 (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text();
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-mirror-"));
56 app = createApp({ dataDir, port: 0 });
57 servers = await serve(app);
58 FIELDS = { endpoint: servers.url, bucket: "offbox-copy", accessKey: "AKIAEXAMPLE", secretKey: SECRET };
59 svc = startSnapshots(app, dirStore(join(dataDir, "store")), {
60 version: "t",
61 nightly: false,
62 mirror: () => resolveMirror({ db: app.db, dataDir, bus: app.bus, env: {} })?.store,
63 });
64 const a = await createUser(app.db, { origin: "seed", trusted: true,
65 username: "alice",
66 email: "[email protected]",
67 password: "a-good-passphrase",
68 adminIfFirst: true,
69 });
70 if (!a.ok) throw new Error(a.error);
71 const b = await createUser(app.db, { origin: "seed", trusted: true,
72 username: "bob",
73 email: "[email protected]",
74 password: "a-good-passphrase",
75 });
76 if (!b.ok) throw new Error(b.error);
77 promote(app.db, b.value);
78 admin = createToken(app.db, a.value.id, "admin", "admin").token;
79 adminRw = createToken(app.db, a.value.id, "rw", "rw").token;
80 other = createToken(app.db, b.value.id, "rw", "rw").token;
81 const login = await fetch(`${servers.url}/signin`, {
82 method: "POST",
83 headers: { origin: servers.url },
84 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
85 redirect: "manual",
86 });
87 cookie = (login.headers.get("set-cookie") ?? "").split(";")[0]!;
88 }, 30_000);
89
90 afterAll(async () => {
91 void svc.stop(); // a pass against the stand-in may still be in flight; the process ends it
92 servers.stop();
93 app.close();
94 await rm(dataDir, { recursive: true, force: true });
95 });
96
97 test("an admin sets a mirror and the page shows it; forget clears it", async () => {
98 const set = await tool(admin, { action: "set", ...FIELDS });
99 expect(set.isError).toBe(false);
100 const said = JSON.parse(set.text);
101 const place = `${servers.url.replace(/^https?:\/\//, "")}/offbox-copy`;
102 expect(said.place).toBe(place);
103 expect(said.says).toContain("sealed");
104 expect(set.text).not.toContain(SECRET);
105 const shown = await page();
106 expect(shown).toContain(place);
107 expect(shown).not.toContain(SECRET);
108
109 const gone = await tool(admin, { action: "forget" });
110 expect(gone.isError).toBe(false);
111 expect(JSON.parse(gone.text).place).toBeNull();
112 expect(await page()).toContain("No off-box mirror");
113 });
114
115 test("a bad field is refused with the action's own sentence, never echoing the key", async () => {
116 const bad = await tool(admin, { action: "set", ...FIELDS, bucket: "X" });
117 expect(bad.isError).toBe(true);
118 expect(bad.text).toContain("bucket name");
119 expect(bad.text).not.toContain(SECRET);
120 expect((await tool(admin, { action: "peek" })).isError).toBe(true);
121 });
outdated

Add a case with alice's rw-scope token: it must be refused, and svc.mirror must stay undefined. Today this test only covers a non-admin.

122
123 test("an admin's rw token is refused: sealing the mirror needs an admin-scope token", async () => {
124 const set = await tool(adminRw, { action: "set", ...FIELDS });
125 expect(set.isError).toBe(true);
126 expect(set.text).not.toContain(SECRET);
127 expect(svc.mirror).toBeUndefined();
128 expect((await tool(adminRw, { action: "forget" })).isError).toBe(true);
129 });
130
131 test("a non-admin is refused", async () => {

Non-blocking: this case and the rw case pass even with the tool missing, because an unknown tool is also isError. Asserting the refusal's own wording, or that tools/list for this token omits mirror_set while the admin-scope list includes it, would pin the gate itself.

132 const set = await tool(other, { action: "set", ...FIELDS });
133 expect(set.isError).toBe(true);
134 expect(set.text).not.toContain(SECRET);
135 expect(svc.mirror).toBeUndefined();
136 });
137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs