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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5480-mcp-passkey-remove-remove-one-of-the-cal → main 8 commits fork 6 threads · 6 unresolved
3 files
changed +142
−4
docs/MCP.md +1 −1
| @@ -152,7 +152,7 @@ for a tunnel and a forge that sleeps. | ||
| 152 | 152 | | `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 | | |
| 153 | 153 | | `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 | |
| 154 | 154 | | `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 | | |
| 156 | 156 | | `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 | |
| 157 | 157 | |
| 158 | 158 | <!-- tools:end --> |
src/http/mcp/tools/whoami_can.ts +16 −3
| @@ -1,23 +1,36 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | 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)", | |
| 8 | 9 | description: |
| 9 | 10 | "What you can do: your role and its roles-table row, yes or no each.", |
| 10 | 11 | inputSchema: { |
| 11 | 12 | type: "object", |
| 12 | 13 | properties: { |
| 13 | 14 | kind: { type: "string", enum: ["role", "passkeys"] }, |
| 15 | remove: { type: "string" }, | |
| 14 | 16 | }, |
| 15 | 17 | }, |
| 16 | writes: false, | |
| 18 | writes: true, | |
| 19 | readsWithout: "remove", | |
| 17 | 20 | run: (ctx, user, args) => { |
| 18 | 21 | const kind = args["kind"] ?? "role"; |
| 22 | if (args["remove"] !== undefined && kind !== "passkeys") return bad("invalid", "remove goes with kind: passkeys"); | |
| 19 | 23 | if (kind === "passkeys") { |
| 20 | 24 | 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 | } | |
| 21 | 34 | return good({ |
| 22 | 35 | items: listPasskeys(ctx.app.db, user.id).map((k) => ({ |
| 23 | 36 | 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 | }); |
new above
offline
Low, not blocking:
writes: truetakes awaywhoami_can'sreadOnlyHintin 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".readsWithoutkeeps read-only tokens working, but the annotation is per tool. If this bothers anyone later, move removal toprofile, which already edits the caller's own account.