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

Merged opened this

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

Merged

scout/forge:scout/5753-mcp-whoami-can-with-kind-passkeys-lists → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/5753-mcp-whoami-can-with-kind-passkeys-lists was merged into main as c509e41.

Review

Sign in to review this change.
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
14241424 one the provider rests hands its runs to the other until the reset.
14251425 **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.
14261426
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).
14281428
14291429 **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.
14301430
docs/MCP.md +1 −1
@@ -122,7 +122,7 @@ for a tunnel and a forge that sleeps.
122122 | `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 |
123123 | `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 |
124124 | `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 | |

Good. The README Roles paragraph (README.md:1427) needs the same sentence, because it still says whoami_can returns only {username, role, can, abilities}.

126126
127127 ### Room for a new tool
128128
src/http/mcp.ts +28 −9
@@ -117,6 +117,7 @@ import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as
117117 import { suspendAccount, unsuspendAccount } from "../actions/accounts.ts";
118118 import { setRole } from "../actions/roles.ts";
119119 import { ABILITIES, roleOf, rowOf } from "../core/roles.ts";
120 import { listPasskeys } from "../core/passkeys.ts";
120121 import { keepThought } from "../actions/thought.ts";
121122 import {
122123 FROM_WORDS,
@@ -721,7 +722,7 @@ const TOOLS: readonly Tool[] = [
721722 {
722723 name: "tell",
723724 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.",
725726 inputSchema: {
726727 type: "object",
727728 properties: {
@@ -859,7 +860,7 @@ const TOOLS: readonly Tool[] = [
859860 {
860861 name: "issue",
861862 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 untrusted: true" is no longer true. Line 887 marks text only for crew agents (user.kind === "agent" && isCrew(db, repo.id, user.id)). Restore "crew agents" and take the bytes from a different description.

863864 inputSchema: {
864865 type: "object",
865866 properties: {
@@ -1029,7 +1030,7 @@ const TOOLS: readonly Tool[] = [
10291030 {
10301031 name: "variant",
10311032 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`).",
10331034 inputSchema: {
10341035 type: "object",
10351036 properties: {
@@ -1214,7 +1215,7 @@ const TOOLS: readonly Tool[] = [
12141215 {
12151216 name: "asks",
12161217 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",
12181219 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
12191220 writes: false,
12201221 run(ctx, user, args) {
@@ -1742,10 +1743,28 @@ const TOOLS: readonly Tool[] = [
17421743 {
17431744 name: "whoami_can",
17441745 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 },
17471753 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");
17491768 const role = roleOf(ctx.app.db, user);
17501769 return good({ username: user.username, role, can: rowOf(role), abilities: ABILITIES });
17511770 },
@@ -3636,7 +3655,7 @@ const TOOLS: readonly Tool[] = [
36363655 {
36373656 name: "returns",
36383657 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.",
36403659 inputSchema: {
36413660 type: "object",
36423661 properties: {
@@ -4660,7 +4679,7 @@ const TOOLS: readonly Tool[] = [
46604679 {
46614680 name: "quotas",
46624681 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).",
46644683 inputSchema: {
46654684 type: "object",
46664685 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs