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

MCP `passkey_remove`: remove one of the caller's own passkeys, only with an admin-scope token, as Settings → Passkeys does #5485

Merged opened this

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

Merged

scout/forge:scout/5480-mcp-passkey-remove-remove-one-of-the-cal → main 8 commits fork 6 threads · 6 unresolved

  • Merged. scout/5480-mcp-passkey-remove-remove-one-of-the-cal was merged into main as b689db4.

Review

Sign in to review this change.
3 files changed +142 −4
docs/MCP.md +1 −1
@@ -152,7 +152,7 @@ for a tunnel and a forge that sleeps.
152152 | `vision` | `{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null | |
153153 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
154154 | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes |
155 | `whoami_can` | `{ kind? }`: your role and that role's row of the roles table, each ability yes or no; `kind: "passkeys"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only | |
155 | `whoami_can` | `{ kind?, remove? }`: your role and that role's row of the roles table, each ability yes or no; `kind: "passkeys"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only; `remove: id` with `kind: "passkeys"` removes one of them, as Settings → Passkeys does (an admin-scope token, never an agent) | yes |
156156 | `write_file` | `{ repo, branch, path, content, message }`: commit one file to an existing branch as `POST …/contents` does, for the same callers and with the same refusals (protected default, too large, a path with `..`), said in `edit_words`' sentences; answers `{ commit, says }` | yes |
157157
158158 <!-- tools:end -->
src/http/mcp/tools/whoami_can.ts +16 −3
@@ -1,23 +1,36 @@
11 import { ABILITIES, roleOf, rowOf } from "../../../core/roles.ts";
2 import { listPasskeys } from "../../../core/passkeys.ts";
2 import { listPasskeys, removePasskey } from "../../../core/passkeys.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
34 import { good, bad, type Tool } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "whoami_can",
7 doc: "`{ kind? }`: your role and that role's row of the roles table, each ability yes or no; `kind: \"passkeys\"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only",
8 doc: "`{ kind?, remove? }`: your role and that role's row of the roles table, each ability yes or no; `kind: \"passkeys\"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only; `remove: id` with `kind: \"passkeys\"` removes one of them, as Settings → Passkeys does (an admin-scope token, never an agent)",
89 description:
910 "What you can do: your role and its roles-table row, yes or no each.",
1011 inputSchema: {
1112 type: "object",
1213 properties: {
1314 kind: { type: "string", enum: ["role", "passkeys"] },
15 remove: { type: "string" },
1416 },
1517 },
16 writes: false,
18 writes: true,

Low, not blocking: writes: true takes away whoami_can's readOnlyHint in tools/list for every caller, including the common call that only reads. A client that auto-approves read-only tools will now prompt for a plain "what can I do". readsWithout keeps read-only tokens working, but the annotation is per tool. If this bothers anyone later, move removal to profile, which already edits the caller's own account.

19 readsWithout: "remove",
1720 run: (ctx, user, args) => {
1821 const kind = args["kind"] ?? "role";
22 if (args["remove"] !== undefined && kind !== "passkeys") return bad("invalid", "remove goes with kind: passkeys");
1923 if (kind === "passkeys") {
2024 if (user.kind === "agent") return bad("forbidden", "an agent has no passkeys");
25 const remove = args["remove"];
26 if (remove !== undefined) {
27 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
28 return bad("forbidden", "removing a passkey needs an admin-scope token");
29 if (typeof remove !== "string" || remove === "" || remove.length > 64)
30 return bad("bad_args", "remove is a passkey id");
31 if (!removePasskey(ctx.app.db, user.id, remove)) return bad("not_found", "No such passkey.");
32 return good({ id: remove, says: "Passkey removed." });
33 }
2134 return good({
2235 items: listPasskeys(ctx.app.db, user.id).map((k) => ({
2336 id: k.id,
test/mcp-passkey-remove.test.ts added +125
@@ -0,0 +1,125 @@
1 /**
2 * The `whoami_can remove` tool removes the caller's own passkey, and only with an
3 * admin-scope token.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { addPasskey, listPasskeys } from "../src/core/passkeys.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { getUserByName } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15
16 describe("MCP whoami_can remove", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let rpc = 1;
21 let n = 0;
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-passkey-"));
25 app = createApp({ dataDir, port: 0, url: "https://forge.example" });
26 servers = await serve(app);
27 const owner = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
28 if (!owner.ok) throw new Error(owner.error);
29 });
30 afterAll(async () => {
31 servers.stop();
32 app.close();
33 await rm(dataDir, { recursive: true, force: true });
34 });
35
36 const person = (name: string) => {
37 const user = getUserByName(app.db, name)!;
38 const key = { kty: "EC", crv: "P-256", x: "x", y: "y" } as const;
39 const added = addPasskey(app.db, user.id, "phone", { id: `cred-${n++}`, alg: "ES256", key, signCount: 0 });
40 if (!added.ok) throw new Error(added.error);
41 return { user, passkey: added.value };
42 };
43
44 const call = async (token: string, id: string, extra: Record<string, unknown> = { kind: "passkeys" }) => {
45 const res = await fetch(`${servers.url}/mcp`, {
46 method: "POST",
47 headers: {
48 "content-type": "application/json",
49 accept: "application/json, text/event-stream",
50 authorization: `Bearer ${token}`,
51 },
52 body: JSON.stringify({ jsonrpc: "2.0", id: rpc++, method: "tools/call", params: { name: "whoami_can", arguments: { ...extra, remove: id } } }),
53 });
54 const body: any = await res.json();
55 return { isError: body.result.isError === true, text: body.result.content[0].text as string };
56 };
57
58 test("an admin-scope token removes the caller's passkey", async () => {
59 const { user, passkey } = person("olive");
60 const res = await call(createToken(app.db, user.id, "t", "admin").token, passkey);
61 expect(res.isError).toBe(false);
62 expect(JSON.parse(res.text)).toEqual({ id: passkey, says: "Passkey removed." });
63 expect(listPasskeys(app.db, user.id).some((k) => k.id === passkey)).toBe(false);
64 });
65
66 test("a read-write token is refused and the passkey stays", async () => {
67 const { user, passkey } = person("olive");
68 const res = await call(createToken(app.db, user.id, "t", "rw").token, passkey);
69 expect(res.isError).toBe(true);
70 expect(res.text).toContain("admin-scope");
71 expect(listPasskeys(app.db, user.id).some((k) => k.id === passkey)).toBe(true);
72 });
73
74 test("another person's passkey is refused and stays", async () => {
75 const res0 = await fetch(`${servers.url}/api/v1/users`, {
76 method: "POST",
77 headers: { "content-type": "application/json" },
78 body: JSON.stringify({ username: "ada", email: "[email protected]", password: "outsider-passphrase" }),
79 });
80 expect(res0.status).toBe(201);
81 const ada = getUserByName(app.db, "ada")!;
82 const { user: olive, passkey } = person("olive");
83 const res = await call(createToken(app.db, ada.id, "t", "admin").token, passkey);
84 expect(res.isError).toBe(true);
85 expect(res.text).toContain("No such passkey.");
86 expect(listPasskeys(app.db, olive.id).some((k) => k.id === passkey)).toBe(true);
87 });
88
89 const signup = async (name: string) => {
90 const res = await fetch(`${servers.url}/api/v1/users`, {
91 method: "POST",
92 headers: { "content-type": "application/json" },
93 body: JSON.stringify({ username: name, email: `${name}@outsider.test`, password: "outsider-passphrase" }),
94 });
95 expect(res.status).toBe(201);
96 return getUserByName(app.db, name)!;
97 };
98
99 test("an agent is refused as a person's call, and the passkey stays", async () => {
100 const bot = await signup("botty");
101 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.id);
102 const { passkey } = person("botty");
103 const res = await call(createToken(app.db, bot.id, "t", "admin").token, passkey);
104 expect(res.isError).toBe(true);
105 expect(res.text).toContain("an agent has no passkeys");
106 expect(listPasskeys(app.db, bot.id).some((k) => k.id === passkey)).toBe(true);
107 });
108
109 test("an empty or over-long id is bad_args", async () => {
110 const user = await signup("cora");
111 const token = createToken(app.db, user.id, "t", "admin").token;
112 for (const id of ["", "x".repeat(65)]) {
113 const res = await call(token, id);
114 expect(res.isError).toBe(true);
115 expect(res.text).toContain("bad_args");
116 }
117 });
118
119 test("remove without kind passkeys is refused, and the passkey stays", async () => {
120 const { user, passkey } = person("olive");
121 const res = await call(createToken(app.db, user.id, "t", "admin").token, passkey, {});
122 expect(res.isError).toBe(true);
123 expect(listPasskeys(app.db, user.id).some((k) => k.id === passkey)).toBe(true);
124 });
125 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs