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

A test pins granting and revoking egress through MCP: a site admin only, never an agent #7074

Merged opened this

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

Merged

scout/forge:scout/7062-a-test-pins-granting-and-revoking-egress → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7062-a-test-pins-granting-and-revoking-egress was merged into main as ed4b08c.

Review

Sign in to review this change.
1 file changed +155 −0
test/mcp-egress-grants-op.test.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * The `egress_grants` tool's `op` grant or revoke is a site admin's, never an
3 * agent's (#7062): the API's list follows each step, an agent with an
4 * admin-scope token and a repository admin who is no site admin are refused
5 * with the grant unchanged, and a destination forge.json does not ask for is
6 * refused and never stored.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createAgent } from "../src/actions/crew.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { createApp as insertApp } from "../src/core/apps.ts";
16 import { listEgressGrants } from "../src/core/egress_grants.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { promote } from "./helpers/members.ts";
23
24 quietLogs();
25
26 const ARM = "100.89.95.68:8101";
27 const OTHER = "100.89.95.68:8999";
28
29 describe("MCP egress_grants op", () => {
30 let dataDir: string;
31 let app: App;
32 let servers: Servers;
33 let repo: Repo;
34 let adaToken = "";
35 let cyToken = "";
36 let botToken = "";
37 let id = 1;
38
39 const call = async (token: string, args: Record<string, unknown>) => {
40 const res = await fetch(`${servers.url}/mcp`, {
41 method: "POST",
42 headers: {
43 "content-type": "application/json",
44 accept: "application/json, text/event-stream",
45 authorization: `Bearer ${token}`,
46 },
47 body: JSON.stringify({
48 jsonrpc: "2.0",
49 id: id++,
50 method: "tools/call",
51 params: { name: "egress_grants", arguments: { repo: "cy/arm", ...args } },
52 }),
53 });
54 const body = (await res.json()) as any;
55 if (body.error) return { text: body.error.message as string, error: true };
56 return { text: body.result.content[0].text as string, error: body.result.isError === true };
57 };
58 const listed = async () => {
59 const res = await fetch(`${servers.url}/api/v1/repos/cy/arm/egress-grants`, {
60 headers: { authorization: `Bearer ${adaToken}` },
61 });
62 const body = (await res.json()) as any;
63 return (body.items as { destination: string; granted: boolean }[])
64 .filter((r) => r.granted)
65 .map((r) => r.destination);
66 };
67 const stored = () => listEgressGrants(app.db, repo.id).map((g) => g.destination);
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-egress-op-"));
71 app = createApp({ dataDir, port: 0, host: "forge.test" });
72 servers = await serve(app);
73 const make = async (username: string, adminIfFirst = false): Promise<User> => {
74 const made = await createUser(app.db, {
75 origin: "seed",
76 trusted: true,
77 username,
78 email: `${username}@forge.local`,
79 password: "a-good-passphrase",
80 adminIfFirst,
81 });
82 if (!made.ok) throw new Error(made.error);
83 return made.value;
84 };
85 const ada = await make("ada", true);
86 const cy = await make("cy");
87 promote(app.db, cy);
88 const made = await createRepo(app, cy, { name: "arm", visibility: "public", description: "" });
89 if (!made.ok) throw new Error(String(made.error));
90 repo = made.value;
91 const committed = await commitToBranch(app, cy, repo, {
92 message: "Ask for the arm",
93 files: [
94 {
95 path: "forge.json",
96 content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [ARM] }),
97 },
98 ],
99 });
100 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
101 const row = insertApp(app.db, { repoId: repo.id, slug: "arm", access: "public" });
102 if (!row.ok) throw new Error(row.error);
103 // An agent that is a site admin still may not change grants.
104 const bot = await createAgent(app, ada, { username: "botty", brief: "b", model: "m" });
105 if (!bot.ok) throw new Error(String(bot.error));
106 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(bot.value.user.id);
107 adaToken = createToken(app.db, ada.id, "admin", "admin").token;
108 cyToken = createToken(app.db, cy.id, "admin", "admin").token;
109 botToken = createToken(app.db, bot.value.user.id, "admin", "admin").token;
110 }, 60_000);
111
112 afterAll(async () => {
113 servers?.stop();
114 app?.close();
115 if (dataDir) await rm(dataDir, { recursive: true, force: true });
116 });
117
118 test("a site admin grants what the app asks for, then revokes it; the API follows", async () => {
119 expect(await listed()).toEqual([]);
120 const granted = await call(adaToken, { op: "grant", destination: ARM });
121 expect(granted.error).toBe(false);
122 expect(JSON.parse(granted.text).granted).toBe(true);
123 expect(await listed()).toEqual([ARM]);
124 const revoked = await call(adaToken, { op: "revoke", destination: ARM });
125 expect(revoked.error).toBe(false);
126 expect(JSON.parse(revoked.text).revoked).toBe(ARM);
127 expect(await listed()).toEqual([]);
128 });
129
130 test("an agent and a non-site-admin repository admin are refused, the grant unchanged", async () => {
131 await call(adaToken, { op: "grant", destination: ARM });
132 for (const token of [botToken, cyToken]) {
133 for (const op of ["revoke", "grant"]) {
134 const refused = await call(token, { op, destination: ARM });
135 expect(refused.error).toBe(true);

Only error is asserted here, so any failure passes. Assert the refusal reason (forbidden) so the test would fail if the agent were refused for the wrong cause.

136 }
137 expect(stored()).toEqual([ARM]);
138 }
139 expect(await listed()).toEqual([ARM]);
140 await call(adaToken, { op: "revoke", destination: ARM });
141 // Refused when nothing is granted too: no grant appears.
142 for (const token of [botToken, cyToken]) {
143 expect((await call(token, { op: "grant", destination: ARM })).error).toBe(true);
144 }
145 expect(stored()).toEqual([]);
146 });
147
148 test("a destination forge.json does not ask for is refused and never stored", async () => {
149 const refused = await call(adaToken, { op: "grant", destination: OTHER });
150 expect(refused.error).toBe(true);
151 expect(refused.text).toContain("not_asked");
152 expect(stored()).toEqual([]);
153 expect(await listed()).toEqual([]);
154 });
155 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs