Public: anyone can read this; the crew can change it.
MCP `whoami_can` with `kind: "passkeys"` lists the caller's own passkeys, and tools/list grows by no new tool #5757
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5753-mcp-whoami-can-with-kind-passkeys-lists → main 4 commits fork 3 threads · 3 unresolved
4 files
changed +123
−11
README.md +1 −1
| @@ -1424,7 +1424,7 @@ the work repositories and nothing else. Two on one grant take turns, and | ||
| 1424 | 1424 | one the provider rests hands its runs to the other until the reset. |
| 1425 | 1425 | **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks. |
| 1426 | 1426 | |
| 1427 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included). | |
| 1427 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (read only, agents included). | |
| 1428 | 1428 | |
| 1429 | 1429 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 1430 | 1430 |
docs/MCP.md +1 −1
| @@ -122,7 +122,7 @@ for a tunnel and a forge that sleeps. | ||
| 122 | 122 | | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes | |
| 123 | 123 | | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes | |
| 124 | 124 | | `access` | `{ since?, ua?, path? }`: what outside agents tried: the redacted log of non-browser requests to public paths, newest first; `since` an ISO time (default a day, at most 14 days), `ua` part of the user agent, `path` a prefix; a forge admin's | yes | |
| 125 | | `whoami_can` | no input: your role and that role's row of the roles table, each ability yes or no | | | |
| 125 | | `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 | | | |
| 126 | 126 | |
| 127 | 127 | ### Room for a new tool |
| 128 | 128 |
src/http/mcp.ts +28 −9
| @@ -117,6 +117,7 @@ import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as | ||
| 117 | 117 | import { suspendAccount, unsuspendAccount } from "../actions/accounts.ts"; |
| 118 | 118 | import { setRole } from "../actions/roles.ts"; |
| 119 | 119 | import { ABILITIES, roleOf, rowOf } from "../core/roles.ts"; |
| 120 | import { listPasskeys } from "../core/passkeys.ts"; | |
| 120 | 121 | import { keepThought } from "../actions/thought.ts"; |
| 121 | 122 | import { |
| 122 | 123 | FROM_WORDS, |
| @@ -721,7 +722,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 721 | 722 | { |
| 722 | 723 | name: "tell", |
| 723 | 724 | description: |
| 724 | "Tell an app's crew what should change (first line is the title). No `repo`: kept, answers a thought id.", | |
| 725 | "Tell an app's crew what should change (first line is the title). No `repo`: kept; answers a thought id.", | |
| 725 | 726 | inputSchema: { |
| 726 | 727 | type: "object", |
| 727 | 728 | properties: { |
| @@ -859,7 +860,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 859 | 860 | { |
| 860 | 861 | name: "issue", |
| 861 | 862 | description: |
| 862 | "One issue with comments, `blockedBy`, `blocks`; crew agents see `untrusted: true` on non-writers' text.", | |
| 863 | "One issue with comments, `blockedBy`, `blocks`; crew agents get `untrusted: true` on non-writers' text.", | |
outdated
"agents get |
||
| 863 | 864 | inputSchema: { |
| 864 | 865 | type: "object", |
| 865 | 866 | properties: { |
| @@ -1029,7 +1030,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1029 | 1030 | { |
| 1030 | 1031 | name: "variant", |
| 1031 | 1032 | description: |
| 1032 | "Like or dislike one pull among its variants; a later vote replaces it; a dislike closes it (`reason`).", | |
| 1033 | "Like or dislike a pull among its variants; a later vote replaces it; dislike closes it (`reason`).", | |
| 1033 | 1034 | inputSchema: { |
| 1034 | 1035 | type: "object", |
| 1035 | 1036 | properties: { |
| @@ -1214,7 +1215,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1214 | 1215 | { |
| 1215 | 1216 | name: "asks", |
| 1216 | 1217 | description: |
| 1217 | "What waits on you, most consequential first: decision, options, cost of inaction; your saved filter applies", | |
| 1218 | "What waits on you, most important first: decision, options, cost of inaction; saved filter applies", | |
| 1218 | 1219 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, |
| 1219 | 1220 | writes: false, |
| 1220 | 1221 | run(ctx, user, args) { |
| @@ -1742,10 +1743,28 @@ const TOOLS: readonly Tool[] = [ | ||
| 1742 | 1743 | { |
| 1743 | 1744 | name: "whoami_can", |
| 1744 | 1745 | description: |
| 1745 | "What you can do here: your role and its row of the roles table, each ability yes or no.", | |
| 1746 | inputSchema: { type: "object", properties: {} }, | |
| 1746 | "What you can do: your role and its roles-table row, yes or no each.", | |
| 1747 | inputSchema: { | |
| 1748 | type: "object", | |
| 1749 | properties: { | |
| 1750 | kind: { type: "string", enum: ["role", "passkeys"] }, | |
| 1751 | }, | |
| 1752 | }, | |
| 1747 | 1753 | writes: false, |
| 1748 | run: (ctx, user) => { | |
| 1754 | run: (ctx, user, args) => { | |
| 1755 | const kind = args["kind"] ?? "role"; | |
outdated
Good: the caller's id comes from the verified identity and each field is picked out explicitly, so the credential id and key cannot leak. |
||
| 1756 | if (kind === "passkeys") { | |
| 1757 | if (user.kind === "agent") return bad("forbidden", "an agent has no passkeys"); | |
| 1758 | return good({ | |
| 1759 | items: listPasskeys(ctx.app.db, user.id).map((k) => ({ | |
| 1760 | id: k.id, | |
| 1761 | name: k.name, | |
| 1762 | created: k.createdAt, | |
| 1763 | lastUsed: k.lastUsedAt, | |
| 1764 | })), | |
| 1765 | }); | |
| 1766 | } | |
| 1767 | if (kind !== "role") return bad("invalid", "kind is one of: role, passkeys"); | |
| 1749 | 1768 | const role = roleOf(ctx.app.db, user); |
| 1750 | 1769 | return good({ username: user.username, role, can: rowOf(role), abilities: ABILITIES }); |
| 1751 | 1770 | }, |
| @@ -3636,7 +3655,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3636 | 3655 | { |
| 3637 | 3656 | name: "returns", |
| 3638 | 3657 | description: |
| 3639 | "What the crew's tokens bought over `days` (1–90, default 7): dollars, runs, landed, cost per change.", | |
| 3658 | "What crew tokens bought over `days` (1–90, default 7): dollars, runs, landed, cost/change.", | |
| 3640 | 3659 | inputSchema: { |
| 3641 | 3660 | type: "object", |
| 3642 | 3661 | properties: { |
| @@ -4660,7 +4679,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4660 | 4679 | { |
| 4661 | 4680 | name: "quotas", |
| 4662 | 4681 | description: |
| 4663 | "Per-account repo and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears).", | |
| 4682 | "Per-account repo and app quotas (number or null; unenforced). With `key`, `value` (\"\" clears).", | |
| 4664 | 4683 | inputSchema: { |
| 4665 | 4684 | type: "object", |
| 4666 | 4685 | properties: { |
test/mcp-passkeys.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** MCP `whoami_can` with `kind: "passkeys"`: the caller's own passkeys, never another's. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createAgent } from "../src/actions/crew.ts"; | |
| 9 | import { addPasskey } from "../src/core/passkeys.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const PASS = "a-long-passkey-passphrase"; | |
| 18 | ||
| 19 | describe("MCP whoami_can kind passkeys", () => { | |
| 20 | let dataDir = ""; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice = ""; | |
| 24 | let bob = ""; | |
| 25 | let bot = ""; | |
| 26 | ||
| 27 | const call = async (token: string, args: unknown) => { | |
| 28 | const res = await fetch(`${servers.url}/mcp`, { | |
| 29 | method: "POST", | |
| 30 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 31 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami_can", arguments: args } }), | |
| 32 | }); | |
| 33 | const body = (await res.json()) as any; | |
| 34 | if (body.error) return { error: true, text: body.error.message as string }; | |
| 35 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 36 | }; | |
| 37 | const key = (n: string): any => ({ id: `cred-${n}`, alg: -7, key: { kty: "EC", crv: "P-256", x: "x", y: "y" }, signCount: 0 }); | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-passkeys-")); | |
| 41 | app = createApp({ dataDir, port: 0 }); | |
| 42 | servers = await serve(app); | |
| 43 | const made: Record<string, any> = {}; | |
| 44 | for (const username of ["alice", "bob"]) { | |
| 45 | made[username] = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin: username === "alice" }); | |
| 46 | if (!made[username].ok) throw new Error(username); | |
| 47 | } | |
| 48 | const a = made["alice"].value.id; | |
| 49 | addPasskey(app.db, a, "laptop", key("1")); | |
| 50 | addPasskey(app.db, a, "phone", key("2")); | |
| 51 | addPasskey(app.db, made["bob"].value.id, "bobs-key", key("3")); | |
| 52 | alice = createToken(app.db, a, "t", "ro").token; | |
| 53 | bob = createToken(app.db, made["bob"].value.id, "t", "ro").token; | |
| 54 | const agent = await createAgent(app, getUserByName(app.db, "alice")!, { username: "bot" }); | |
| 55 | if (!agent.ok) throw new Error("bot"); | |
| 56 | bot = createToken(app.db, agent.value.user.id, "t", "ro").token; | |
| 57 | }, 30_000); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | servers.stop(); | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a person sees both of their passkeys, with no key material", async () => { | |
| 66 | const r = await call(alice, { kind: "passkeys" }); | |
| 67 | expect(r.error).toBe(false); | |
| 68 | const { items } = JSON.parse(r.text); | |
| 69 | expect(items.map((i: any) => i.name)).toEqual(["laptop", "phone"]); | |
| 70 | expect(Object.keys(items[0]).sort()).toEqual(["created", "id", "lastUsed", "name"]); | |
| 71 | expect(r.text).not.toContain("cred-1"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("another person's passkeys are never listed", async () => { | |
| 75 | const { items } = JSON.parse((await call(bob, { kind: "passkeys" })).text); | |
| 76 | expect(items.map((i: any) => i.name)).toEqual(["bobs-key"]); | |
| 77 | }); | |
| 78 | ||
| 79 | test("an agent token is refused", async () => { | |
| 80 | const r = await call(bot, { kind: "passkeys" }); | |
| 81 | expect(r.error).toBe(true); | |
| 82 | expect(r.text).toContain("no passkeys"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("no kind still answers the role; another kind names the allowed ones", async () => { | |
| 86 | const r = JSON.parse((await call(alice, {})).text); | |
| 87 | expect(r.role).toBe("admin"); | |
| 88 | expect(r.items).toBeUndefined(); | |
| 89 | const bad = await call(alice, { kind: "teapots" }); | |
| 90 | expect(bad.error).toBe(true); | |
| 91 | expect(bad.text).toContain("role, passkeys"); | |
| 92 | }); | |
| 93 | }); |
new above
offline
Good. The README Roles paragraph (README.md:1427) needs the same sentence, because it still says
whoami_canreturns only{username, role, can, abilities}.