Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

No token can mint a token, an agent or a sandbox (#42)

Tokens could turn themselves into wider or longer-lived credentials:

- Web forms ignored token scope and repository limits, and a non-browser
  client can send its own double-submit CSRF cookie, so any token could
  create an unrestricted admin token at /settings/tokens. Web forms now
  refuse any request that carries a token.
- POST /diffinery/tokens, /diffinery/agents and /diffinery/agents/:login/tokens
  accepted an admin token. Minting now needs a browser session.
- Imports (REST and MCP) didn't refuse repository-limited tokens.
- Job and sandbox tokens could start sandboxes, whose tokens never expired.
  They can't any more; sandbox tokens last 24 hours at most and stop as
  soon as their creator is disabled or loses access.
- The live test checks that a token can't mint one through the API or the
  web forms.

Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo
Canberk Demirci authored · GitHub committed signed1 parent 728ca60commit f67cee5fa7b7a19555b73dc7b1df52989d94b4ceBrowse files
22 files changed+136−63
Files (22)
modified.github/workflows/live-test.yml+17−1View file
@@ -2,7 +2,7 @@
22
33 # End-to-end checks against the real diffinery.com after each deploy: email
44 # sending, web UI features (syntax highlighting, settings pages, view transitions), hosted Actions jobs in Cloudflare Containers (with Docker builds and services), the container registry, agent sandboxes,
5-# webhooks, scheduled workflows, Git LFS, imports from GitHub, and pull requests (test merge commits, pull_request_target and the merge
5+# webhooks, scheduled workflows, Git LFS, imports from GitHub, that tokens can't mint tokens, and pull requests (test merge commits, pull_request_target and the merge
66 # queue). Each run works in a throwaway private repository that it
77 # deletes at the end.
88 #
@@ -367,6 +367,22 @@
367367 echo "$hook" | jq '{target: .config.url, last_response, updated_at}'
368368 [ "$(jq -r '.last_response.code // empty' <<<"$hook")" != "" ] || { echo "::error::the push webhook was never delivered"; exit 1; }
369369
370+ - name: Tokens can't mint tokens (API or web forms)
371+ if: steps.token.outputs.skip != 'true'
372+ run: |
373+ set -euo pipefail
374+ # Revokes anything a failed check minted, then fails.
375+ minted() { echo "::error::$1"; curl -fsS "$DIFFINERY/api/v3/diffinery/tokens" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name == "live-test escalation") | .id' | while read -r id; do curl -sS -o /dev/null -X DELETE "$DIFFINERY/api/v3/diffinery/tokens/$id" -H "Authorization: Bearer $TOKEN"; done; exit 1; }
376+ code=$(curl -sS -o /tmp/out -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/tokens" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"name":"live-test escalation","scopes":["read"],"expires_in_days":1}')
377+ [ "$code" = 403 ] || minted "an admin token minted a token through the API ($code)"
378+ grep -q "browser session" /tmp/out || minted "unexpected refusal: $(cat /tmp/out)"
379+ code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/api/v3/diffinery/agents" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"login":"live-test-escalation"}')
380+ [ "$code" = 403 ] || { echo "::error::an admin token created an agent ($code); disable live-test-escalation by hand"; exit 1; }
381+ # A web form with a token and a forged double-submit CSRF pair.
382+ code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$DIFFINERY/settings/tokens" -H "Authorization: Bearer $TOKEN" -H "cookie: dfy_csrf=forged" --data "_csrf=forged&name=live-test%20escalation&scope=read&days=1")
383+ [ "$code" = 403 ] || minted "a token submitted the web token form ($code)"
384+ echo "tokens can't mint tokens or agents, through the API or the web forms"
385+
370386 - name: Agent sandbox
371387 if: steps.token.outputs.skip != 'true'
372388 run: |
modifiedapps/cli/src/commands/admin.ts+2−1View file
@@ -119,7 +119,7 @@
119119 const tokenCreate: Command = {
120120 name: "token create",
121121 summary: "Create a personal access token (printed once)",
122- description: "Needs an admin-scoped token (or create tokens in the web UI).",
122+ description: "Tokens can't create tokens, so on diffinery.com this needs the web UI (Settings → Access tokens); the command works against a local development server.",
123123 options: {
124124 name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" },
125125 scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" },
@@ -178,6 +178,7 @@
178178 const agentCreate: Command = {
179179 name: "agent create",
180180 summary: "Create an agent account and print its token once",
181+ description: "Creating an agent mints its first token, which tokens can't do: on diffinery.com create agents in the web UI (Settings → Agents); the command works against a local development server.",
181182 args: "<login>",
182183 options: {
183184 org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" },
modifiedapps/cli/src/commands/auth.ts+1−1View file
@@ -18,7 +18,7 @@
1818 summary: "Log in with a personal or agent token",
1919 description: `Stores the server URL and token in the config file (mode 0600).
2020 The token is read from --token, from stdin when piped, or prompted for.
21-Create a token in the web UI (Settings → Access tokens) or with 'diffinery token create'.`,
21+Create a token in the web UI (Settings → Access tokens).`,
2222 options: {
2323 url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
2424 token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" },
modifiedapps/diffinery/docs/accounts/tokens.md+8−7View file
@@ -47,12 +47,13 @@
4747 | Mark notifications read (`PUT /api/v3/notifications`), change notification settings, send yourself a test email | `write` or `admin` | Refused |
4848 | Watch or unwatch a repository (`PUT`/`DELETE /api/v3/repos/{owner}/{repo}/subscription`) | `write` or `admin` | Allowed for the listed repositories |
4949 | Upload or remove a profile picture (`/api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`) | `write` or `admin` | Refused |
50-| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | `admin` | Allowed |
50+| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | No token can: a browser session only | Refused |
5151 | Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused |
5252 | Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused |
5353 | Invite people (`POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations`, `diffinery invite`) | `admin` | Refused |
5454 | List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused |
55-| Create an agent, create a token for an agent, disable an agent (`/api/v3/diffinery/agents`, `diffinery agent create`) | `admin` | Refused |
55+| Create an agent or a token for an agent (`POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `diffinery agent create`) | No token can: a browser session only | Refused |
56+| Disable an agent (`DELETE /api/v3/diffinery/agents/{login}`) | `admin` | Refused |
5657 | Send every email template (site administrators) | `admin` | Refused |
5758 | Manage passkeys and browser sessions; change your username, display name or appearance | Not possible with a token; use the browser | |
5859
@@ -85,7 +86,7 @@
8586 diffinery login
8687 ```
8788
88-Then create a token. The CLI must be logged in with an `admin`-scoped token:
89+No token can create another token, so on diffinery.com `diffinery token create` gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`: create tokens in the web UI. The command works against a local development server (see [Deploy](/docs/self-hosting/deploy)):
8990
9091 ```sh
9192 # A write token for CI, valid for 30 days, only for acme/website
@@ -104,11 +105,11 @@
104105
105106 ### With the API
106107
107-`POST /api/v3/diffinery/tokens`, with an `admin`-scoped token or from a browser session:
108+`POST /api/v3/diffinery/tokens`, from a browser session (the session cookie you get by signing in with a passkey). No token can create a token, whatever its scope, so a leaked or limited token can never turn itself into a wider or longer-lived one:
108109
109110 ```sh
110111 curl -X POST https://diffinery.com/api/v3/diffinery/tokens \
111- -H "Authorization: Bearer dfy_..." \
112+ -H "Cookie: __Host-dfy_session=..." \
112113 -H "Content-Type: application/json" \
113114 -d '{"name": "ci", "scopes": ["write"], "repositories": ["acme/website"], "expires_in_days": 30}'
114115 ```
@@ -126,7 +127,7 @@
126127 { "token": "dfy_...", "id": "9b2f0c1d4e5a6b7c8d9e0f1a2b3c4d5e", "expiresAt": "2027-01-11T09:30:00.000Z" }
127128 ```
128129
129-A token without the `admin` scope gets `403 creating tokens requires an admin-scoped token or a browser session`. The CLI checks scope names before sending and exits with `unknown scope "<name>" (read, write or admin)`.
130+Any request authenticated with a token (personal, agent, job or sandbox) gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. The CLI checks scope names before sending and exits with `unknown scope "<name>" (read, write or admin)`.
130131
131132 ## Restrict a token to repositories
132133
@@ -264,7 +265,7 @@
264265 | `403 this token needs the admin scope` | Account-level actions such as inviting, creating organizations or agents, organization secrets and revoking tokens need an `admin` token. |
265266 | `403 this token is limited to repositories and can't change account settings` | The token has a repository list. Use a token without one, or the web UI. |
266267 | `422 scopes must be a non-empty list of: read, write, admin` | The `scopes` field was empty, not a list, or had an unknown name. |
267-| `403 creating tokens requires an admin-scoped token or a browser session` | Use an `admin` token or the web UI to create tokens. |
268+| `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` | Tokens can't create tokens. Create it in the web UI. |
268269 | `403 this needs a write or admin token` | Watching or unwatching a repository needs a `write` or `admin` token. |
269270 | `403 changing a profile picture needs a write or admin token that is not limited to repositories` | Use a `write` or `admin` token without a repository restriction, or the web UI. |
270271 | The CLI adds "token invalid or expired; run `diffinery login`" | Run `diffinery login` with a new token. If `DIFFINERY_TOKEN` is set, it overrides the saved token. |
modifiedapps/diffinery/docs/agents/mcp.md+1−1View file
@@ -38,7 +38,7 @@
3838
3939 ## Connect your client
4040
41-Create a token first: for an agent, in **Settings → Agents** or with `diffinery agent create` (which needs an `admin`-scoped token or a browser session); for yourself, in **Settings → Access tokens**. Keep it out of files you commit; most clients below can read it from an environment variable.
41+Create a token first in the web UI: for an agent, in **Settings → Agents**; for yourself, in **Settings → Access tokens**. Tokens can't create tokens. Keep it out of files you commit; most clients below can read it from an environment variable.
4242
4343 ### Claude Code
4444
modifiedapps/diffinery/docs/agents/overview.md+3−3View file
@@ -33,14 +33,14 @@
3333
3434 ### With the CLI
3535
36-Install the CLI (needs Node.js 22 or later) and log in as yourself, with a personal token that has the `admin` scope and isn't restricted to repositories (creating and disabling agents and their tokens are account-level actions):
36+Creating an agent mints its first token, and no token can create a token, so on diffinery.com create agents in the web UI (above). `diffinery agent create` works against a local development server. Listing and disabling agents work from the CLI with a personal token that has the `admin` scope and isn't restricted to repositories:
3737
3838 ```sh
3939 curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
4040 diffinery login
4141 ```
4242
43-Then create the agent. The command also creates its first token and prints it once:
43+On a local development server, create the agent like this. The command also creates its first token and prints it once:
4444
4545 ```sh
4646 diffinery agent create review-bot --org acme --name "Review bot" --scope write --expires-days 90
@@ -76,7 +76,7 @@
7676
7777 `token.scopes` (and `scopes` when you create another token) must be a non-empty list of `read`, `write` and `admin`; leaving it out gives `["write"]`. An empty list or an unknown name returns `422 scopes must be a non-empty list of: read, write, admin`.
7878
79-The `POST` and `DELETE` endpoints below need a browser session or a personal token with the `admin` scope that isn't restricted to repositories. Other tokens get `403 this token needs the admin scope` or `403 this token is limited to repositories and can't change account settings`.
79+The `POST` endpoints below create tokens, so they need a browser session: every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. `DELETE` also accepts a personal token with the `admin` scope that isn't restricted to repositories.
8080
8181 | Endpoint | Purpose | Who |
8282 | --- | --- | --- |
modifiedapps/diffinery/docs/agents/sandboxes.md+2−1View file
@@ -26,7 +26,8 @@
2626
2727 - Creating and using a sandbox needs **write** access to the repository. A `read`-scoped token can't create one.
2828 - Each call on a sandbox is allowed for the account that created it and for repository admins; anyone else gets `not your sandbox`.
29-- Inside the sandbox, git authenticates as the creator, with the role the creator had when the sandbox was created, capped at write (even if the creator is an admin). Pushes from the sandbox follow the same rules as any push: branch protection, push protection (secret scanning) and, for agents, the [agent guardrails](/docs/agents/overview#guardrails). An agent can't push to the default branch from a sandbox either.
29+- Inside the sandbox, git authenticates as the creator, with the role the creator had when the sandbox was created, capped at write (even if the creator is an admin) and at the creator's current role. The sandbox's credentials stop working 24 hours after it starts, when it is closed, or as soon as the creator's account is disabled or loses access to the repository.
30+- Job and sandbox tokens can't create sandboxes (`403 job and sandbox tokens can't start sandboxes`). Pushes from the sandbox follow the same rules as any push: branch protection, push protection (secret scanning) and, for agents, the [agent guardrails](/docs/agents/overview#guardrails). An agent can't push to the default branch from a sandbox either.
3031 - Archived repositories can't have sandboxes.
3132
3233 ## What's inside
modifiedapps/diffinery/docs/quickstart.md+1−7View file
@@ -223,14 +223,8 @@
223223
224224 **Web UI.** Go to **Settings → Agents**. Under **Create an agent**, enter the **Agent username** `review-bot`, a **Display name**, the **Owner** (you or `acme`) and the **Maximum role** (keep **Write**). Click **Create agent**. Then open **Create a token for review-bot**, keep the defaults (**Scope** **Write**, 30 **Days**) and click **Create token**. Copy the token; it is shown once.
225225
226-**CLI.** Creating an agent is an account-level action, so the CLI needs a token with the `admin` scope (the `write` token from step 2 gets `this token needs the admin scope`). Create an `admin` token under **Settings → Access tokens** first, then:
226+Creating an agent also creates its first token, and tokens can't create tokens, so create agents in the web UI under **Settings → Agents**. It shows the agent's token once (scope `write`, valid for 90 days). An agent owned by `acme` can work in every `acme` repository, up to its maximum role. Creating an organization agent needs the owner or admin role in the organization.
227227
228-```sh
229-DIFFINERY_TOKEN=dfy_... diffinery agent create review-bot --org acme
230-```
231-
232-This prints the agent's token once (scope `write`, valid for 90 days). An agent owned by `acme` can work in every `acme` repository, up to its maximum role. Creating an organization agent needs the owner or admin role in the organization.
233-
234228 ### Point your MCP client at Diffinery
235229
236230 Diffinery's MCP server is at `https://diffinery.com/mcp` (Streamable HTTP). Give the client the agent's token as a bearer token:
modifiedapps/diffinery/docs/reference/limits.md+2−1View file
@@ -31,7 +31,8 @@
3131 | An agent's first token | 90 days, `write` scope | The token created together with an agent, unless you pass `token.expires_in_days` or `token.scopes`. |
3232 | Expiry reminder email | 7 days before expiry | Sent once per token, and only for tokens that were valid for more than 8 days. |
3333 | "Last used" accuracy | 1 hour | A token's last-used time is updated at most once an hour. |
34-| Creating tokens through the API | `admin` scope or a browser session | `POST /api/v3/diffinery/tokens` with a `read` or `write` token returns `403 creating tokens requires an admin-scoped token or a browser session`. |
34+| Creating tokens through the API | A browser session | No token can create a token or an agent: `POST /api/v3/diffinery/tokens` and the agent endpoints return `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` for every token. |
35+| Sandbox token lifetime | 24 hours | A sandbox's git and API credentials stop working 24 hours after it starts, or earlier when it is closed or its creator loses access. |
3536 | Token scopes | `read`, `write` or `admin`; default `["write"]` | A new token (personal or agent) without `scopes` gets `write`. An empty list or any other name returns `422 scopes must be a non-empty list of: read, write, admin`. Repeated names are stored once. |
3637 | Writes with a `read` token | Refused | Every change returns `403 this token needs the write scope`. Git fetch and Git LFS downloads still work. |
3738 | Account-level actions | `write` to create repositories, mark notifications read, change notification settings and send a test email; `admin` for creating organizations, invitations, organization secrets, agents and revoking tokens | A token restricted to some repositories can't do any of these (`403 this token is limited to repositories and can't change account settings`). |
modifiedapps/diffinery/docs/reference/rest-api.md+5−4View file
@@ -105,7 +105,7 @@
105105
106106 ## Authentication
107107
108-Most endpoints need you to be signed in. Scripts, CI jobs, agents and the CLI authenticate with a personal access token or an agent token. Tokens start with `dfy_`. Create one under **Settings → Access tokens** (`/settings/tokens`), with `diffinery token create`, or with `POST /api/v3/diffinery/tokens` (see [Personal access tokens](/docs/accounts/tokens)).
108+Most endpoints need you to be signed in. Scripts, CI jobs, agents and the CLI authenticate with a personal access token or an agent token. Tokens start with `dfy_`. Create one under **Settings → Access tokens** (`/settings/tokens`). No token can create another token, so `POST /api/v3/diffinery/tokens` needs a browser session (see [Personal access tokens](/docs/accounts/tokens)).
109109
110110 Send the token in the `Authorization` header. Three schemes are accepted, so any GitHub client works:
111111
@@ -165,12 +165,13 @@
165165 | `PUT /api/v3/notifications`, `PUT /api/v3/diffinery/notification-settings`, `POST /api/v3/diffinery/emails/test` | A `write` or `admin` token not restricted to repositories (`403 this token needs the write scope` for a `read` token). |
166166 | `PUT` and `DELETE /api/v3/repos/{owner}/{repo}/subscription` | A `write` or `admin` token (`403 this needs a write or admin token`). |
167167 | `PUT` and `DELETE /api/v3/user/avatar` and `/api/v3/orgs/{org}/avatar` | A `write` or `admin` token that is not limited to repositories (`403 changing a profile picture needs a write or admin token that is not limited to repositories`). |
168-| `POST /api/v3/diffinery/tokens` | An `admin` token (`403 creating tokens requires an admin-scoped token or a browser session` otherwise). |
168+| `POST /api/v3/diffinery/tokens` | A browser session. Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. |
169169 | `DELETE /api/v3/diffinery/tokens/{id}` | An `admin` token not restricted to repositories. |
170170 | `POST /api/v3/user/orgs` | An `admin` token not restricted to repositories. |
171171 | `POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations` | An `admin` token not restricted to repositories. |
172172 | `GET`, `PUT` and `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | An `admin` token not restricted to repositories. |
173-| `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. |
173+| `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens` | A browser session (they mint tokens). Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. |
174+| `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. |
174175 | `POST /api/v3/diffinery/admin/emails/samples` | A site administrator with an `admin` token not restricted to repositories. |
175176
176177 A token whose scope is too low for one of these gets `403 this token needs the write scope` or `403 this token needs the admin scope`.
@@ -190,7 +191,7 @@
190191
191192 Requests that carry a token in the `Authorization` header are exempt: they carry no ambient credentials, so this check does not apply to curl, scripts, the CLI, MCP clients or runners. If you see `cross-site request refused` from a script, it means the script sent no token; add the `Authorization` header.
192193
193-Machines should always use tokens, never browser cookies. Passkeys and browser sessions can only be managed in the web UI; tokens can't add or remove passkeys.
194+Machines should always use tokens, never browser cookies. Passkeys and browser sessions can only be managed in the web UI; tokens can't add or remove passkeys, and they can't create tokens or agents. Web forms (`/settings/...`, repository settings and the other pages' buttons) refuse requests that carry a token with `403 web forms need a browser session; use the API with a token`, so a token's scope and repository limits always apply.
194195
195196 ## Read-your-writes and the x-d1-bookmark header
196197
modifiedapps/diffinery/docs/repositories/git.md+1−1View file
@@ -37,7 +37,7 @@
3737
3838 Anonymous callers can only read: they can clone and fetch public repositories, but every push needs a token. A `read`-scoped token works for clone and fetch (and for downloading [Git LFS](/docs/repositories/git-lfs) files) on any repository its account can read, and is refused for every push.
3939
40-Create a token under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`) or with `diffinery token create`. See [Personal access tokens](/docs/accounts/tokens).
40+Create a token under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). See [Personal access tokens](/docs/accounts/tokens).
4141
4242 When Git asks for credentials:
4343
modifiedapps/diffinery/src/auth/principal.ts+19−1View file
@@ -120,6 +120,18 @@
120120 if (p.type === "account" && p.via === "token" && p.repoIds) throw new HttpError(403, "this token is limited to repositories and can't change account settings");
121121 }
122122
123+/**
124+ * Minting credentials (tokens, agents and their tokens) needs a person signed in with a
125+ * passkey. No token can create another, so a leaked, repository-limited, agent, job or
126+ * sandbox token can never turn itself into a wider or longer-lived one.
127+ */
128+export function requireBrowserSession(c: AppContext): Account {
129+ const p = c.get("principal");
130+ if (p.type === "account" && (p.via === "session" || p.via === "dev")) return requireAccount(c);
131+ if (p.type === "anonymous") throw new HttpError(401, "sign in required");
132+ throw new HttpError(403, `creating tokens needs a browser session: sign in at ${c.env.PUBLIC_URL}/settings/tokens`);
133+}
134+
123135 /** Validates scopes sent by a client: known names only, default write. */
124136 export function parseScopes(input: unknown): TokenScope[] {
125137 if (input === undefined || input === null) return ["write"];
@@ -147,7 +159,13 @@
147159 if (auth.kind === "job") {
148160 return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: auth.actorKind === "agent" ? "agent" : "system", role: auth.role, permissions: auth.permissions };
149161 }
150- const cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role;
162+ // A sandbox acts as the person or agent who started it, never beyond what they can do now:
163+ // disabling the account or losing access ends its sandboxes' tokens too.
164+ const account = await dir.account(auth.caller.id);
165+ const now = account?.status === "active" ? await dir.roleFor(account, repo) : null;
166+ if (!now) return null;
167+ let cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role;
168+ if (ROLE_LEVEL[now] < ROLE_LEVEL[cap]) cap = now;
151169 return { ...auth.caller, role: cap };
152170 }
153171 if (p.type !== "account") {
modifiedapps/diffinery/src/docs/api-meta.ts+3−3View file
@@ -1134,7 +1134,7 @@
11341134 "GET /diffinery/tokens": { summary: "List your tokens", access: "account" },
11351135 "POST /diffinery/tokens": {
11361136 summary: "Create a token",
1137- description: "From a browser session or an admin-scoped token. The token value is returned once. Unknown scopes or an empty list fail with 422.",
1137+ description: "From a browser session only: no token can create a token. The token value is returned once. Unknown scopes or an empty list fail with 422.",
11381138 access: "account",
11391139 status: 201,
11401140 body: {
@@ -1148,7 +1148,7 @@
11481148 "GET /diffinery/agents": { summary: "List your agents", description: "Agents you own, and agents of organizations you administer.", access: "account" },
11491149 "POST /diffinery/agents": {
11501150 summary: "Create an agent",
1151- description: "People only. Returns the agent and its first token (write scope, 90 days, unless you say otherwise). Unknown scopes or an empty list fail with 422.",
1151+ description: "People, from a browser session only (no token can create a token). Returns the agent and its first token (write scope, 90 days, unless you say otherwise). Unknown scopes or an empty list fail with 422.",
11521152 access: "account",
11531153 status: 201,
11541154 body: {
@@ -1161,7 +1161,7 @@
11611161 },
11621162 "POST /diffinery/agents/{login}/tokens": {
11631163 summary: "Create a token for an agent",
1164- description: "The agent's owner, or an owner or admin of the organization that owns it. Default scope `write`; unknown scopes or an empty list fail with 422. Audited as `agent.token.create`.",
1164+ description: "From a browser session only (no token can create a token): the agent's owner, or an owner or admin of the organization that owns it. Default scope `write`; unknown scopes or an empty list fail with 422. Audited as `agent.token.create`.",
11651165 access: "account",
11661166 status: 201,
11671167 body: { name: ["string", "Token name."], scopes: ["string[]", "`read`, `write` (default) and/or `admin`."], expires_in_days: ["integer", "Lifetime in days."] },
modifiedapps/diffinery/src/http/api.ts+8−9View file
@@ -1,7 +1,7 @@
11 import { Hono } from "hono";
22 import { fromUtf8 } from "@diffinery/git-core";
33 import type { Env } from "../env.js";
4-import { callerFor, parseScopes, requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js";
4+import { callerFor, parseScopes, requireAccount, requireAccountScope, requireBrowserSession, type AppContext, type AppVars } from "../auth/principal.js";
55 import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
66 import { encryptSecret } from "../lib/crypto.js";
77 import { publicJwk } from "../lib/jwt.js";
@@ -913,10 +913,8 @@
913913
914914 api.get("/diffinery/tokens", async (c) => c.json(await c.get("dir").tokens(requireAccount(c).id)));
915915 api.post("/diffinery/tokens", async (c) => {
916- // Minting a token needs a browser session or an admin token for the whole account: a token limited
917- // to some repositories must not be able to create one that isn't.
918- requireAccountScope(c, "admin");
919- const a = requireAccount(c);
916+ // Minting a token needs a browser session: no token (however wide) can create another.
917+ const a = requireBrowserSession(c);
920918 const b = await body<{ name: string; scopes?: TokenScope[]; repositories?: string[]; expires_in_days?: number }>(c);
921919 const scopes = parseScopes(b.scopes);
922920 let repoIds: string[] | null = null;
@@ -945,8 +943,8 @@
945943 return c.json((await c.get("dir").agentsOwnedBy([a.id, ...orgs])).map((x) => ({ ...userJson(c, x), owner_id: x.owner_id, policy: x.agent_policy ? JSON.parse(x.agent_policy) : {} })));
946944 });
947945 api.post("/diffinery/agents", async (c) => {
948- requireAccountScope(c, "admin");
949- const a = requireAccount(c);
946+ // Creating an agent mints its first token, so it needs a browser session too.
947+ const a = requireBrowserSession(c);
950948 if (a.kind !== "user") throw new HttpError(403, "only people can create agents");
951949 const b = await body<{ login: string; name?: string; org?: string; policy?: Record<string, unknown>; token?: { scopes?: TokenScope[]; expires_in_days?: number } }>(c);
952950 const dir = c.get("dir");
@@ -957,8 +955,7 @@
957955 return c.json({ agent: userJson(c, agent), token }, 201);
958956 });
959957 api.post("/diffinery/agents/:login/tokens", async (c) => {
960- requireAccountScope(c, "admin");
961- const a = requireAccount(c);
958+ const a = requireBrowserSession(c);
962959 const dir = c.get("dir");
963960 const agent = await dir.accountByLogin(c.req.param("login"));
964961 if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found");
@@ -984,6 +981,8 @@
984981 });
985982
986983 api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => {
984+ // A sandbox's token outlives the job that would start it, so job and sandbox tokens can't start one.
985+ if (c.get("principal").type === "repo-token") throw new HttpError(403, "job and sandbox tokens can't start sandboxes");
987986 const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
988987 const b = await body<{ ref?: string }>(c).catch(() => ({}) as { ref?: string });
989988 return c.json(await stub.createSandbox(caller, b.ref), 201);
modifiedapps/diffinery/src/http/imports.ts+2−0View file
@@ -105,6 +105,8 @@
105105
106106 imports.post("/diffinery/imports", async (c) => {
107107 const a = actor(c);
108+ // An import creates a repository and mints its push token: not for tokens limited to repositories.
109+ requireAccountScope(c, "write");
108110 const svc = new ImportService(c.env);
109111 const row = await svc.create(a, scopesOf(c), await body(c));
110112 return c.json(await importJson(c.env, svc, row, { children: true }), 201);
modifiedapps/diffinery/src/mcp/server.ts+1−0View file
@@ -453,6 +453,7 @@
453453 required: ["source", "owner"],
454454 run: async (c, a) => {
455455 const me = requireAccount(c);
456+ requireAccountScope(c, "write");
456457 const svc = new ImportService(c.env);
457458 const row = await svc.create(me, importScopes(c), {
458459 source: str(a, "source"),
modifiedapps/diffinery/src/repo/actions.ts+6−1View file
@@ -31,6 +31,9 @@
3131 import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js";
3232 import { ancestors, combineResults, jobStatus, runnerLabels, type JobNode } from "./job-graph.js";
3333
34+/** How long a sandbox token works after the sandbox starts. */
35+export const SANDBOX_TOKEN_TTL_MS = 24 * 3600_000;
36+
3437 const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"];
3538 const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i;
3639
@@ -1121,8 +1124,10 @@
11211124 return { kind: "job", jobId: j.id, runId: j.run_id, role: grant.role, permissions: grant.permissions, actorKind };
11221125 }
11231126 if (parts[0] === "dst") {
1124- const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0];
1127+ const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string; created_at: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0];
11251128 if (!s || s.secret !== hash || s.status !== "running") return null;
1129+ // A sandbox's token lasts a day at most, even if nobody closes the sandbox.
1130+ if (Date.now() - Date.parse(s.created_at) > SANDBOX_TOKEN_TTL_MS) return null;
11261131 return { kind: "sandbox", sandboxId: s.id, caller: JSON.parse(s.created_by) as Caller };
11271132 }
11281133 return null;
modifiedapps/diffinery/src/web/csrf.ts+4−1View file
@@ -1,5 +1,5 @@
11 import { getCookie, setCookie } from "hono/cookie";
2-import type { AppContext } from "../auth/principal.js";
2+import { credentialFrom, type AppContext } from "../auth/principal.js";
33 import { HttpError, randomToken, safeEqual } from "../lib/util.js";
44
55 /**
@@ -82,6 +82,9 @@
8282
8383 /** Reject state-changing requests without a matching token (and cross-origin requests). */
8484 export async function verifyCsrf(c: AppContext, maxBytes = MAX_FORM_BYTES): Promise<FormData> {
85+ // Web forms are for people in a browser. A token (personal, agent, job or sandbox) uses the API,
86+ // where its scope and repository limits apply; here it could forge the double-submit cookie.
87+ if (credentialFrom(c.req.raw)) throw new HttpError(403, "web forms need a browser session; use the API with a token");
8588 const origin = c.req.header("origin");
8689 if (origin && origin !== "null") {
8790 const self = new URL(c.req.url).origin;
modifiedapps/diffinery/test/e2e/e2e.test.mjs+8−8View file
@@ -41,7 +41,7 @@
4141 async function api(method, path, body, tok = token) {
4242 const res = await fetch(`${base}/api/v3${path}`, {
4343 method,
44- headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
44+ headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" },
4545 body: body === undefined ? undefined : JSON.stringify(body),
4646 });
4747 const text = await res.text();
@@ -267,7 +267,7 @@
267267 git(clone, ["reset", "-q", "--hard", "origin/main"]);
268268
269269 // ------------------------------------------------------------ an AI agent opens a PR
270- const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" });
270+ const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" }, null);
271271 agentToken = created.token.token;
272272 const agentClone = join(work, "agent");
273273 git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken);
@@ -299,7 +299,7 @@
299299 assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge));
300300
301301 // Another agent's approval is advisory and does not unblock the merge.
302- const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" })).token.token;
302+ const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" }, null)).token.token;
303303 await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "bot says ok" }, reviewer);
304304 assert.equal((await ok("GET", `/repos/${owner}/demo/pulls/1/merge`)).mergeable, false);
305305
@@ -656,7 +656,7 @@
656656 test("triggers from workflows: a job's repository_dispatch starts runs, a follower of agent code stays an agent's", { timeout: 600_000 }, async () => {
657657 const owner = (await ok("GET", "/user")).login;
658658 await ok("POST", "/user/repos", { name: "relay" });
659- const bot = (await ok("POST", "/diffinery/agents", { login: "relay-bot" })).token.token;
659+ const bot = (await ok("POST", "/diffinery/agents", { login: "relay-bot" }, null)).token.token;
660660 const src = join(work, "relay");
661661 mkdirSync(join(src, ".github/workflows"), { recursive: true });
662662 writeFileSync(
@@ -994,7 +994,7 @@
994994 const settingsPath = `/repos/${owner}/policy/actions/permissions/workflow`;
995995 // New repositories start like GitHub's: read-only token, no pull request approvals.
996996 assert.deepEqual(await ok("GET", settingsPath), { default_workflow_permissions: "read", can_approve_pull_request_reviews: false });
997- const bot = (await ok("POST", "/diffinery/agents", { login: "policy-bot" })).token.token;
997+ const bot = (await ok("POST", "/diffinery/agents", { login: "policy-bot" }, null)).token.token;
998998 assert.equal((await api("PUT", settingsPath, { can_approve_pull_request_reviews: true }, bot)).status, 403, "agents cannot change what workflows may do");
999999 assert.equal((await api("PUT", settingsPath, { default_workflow_permissions: "admin" })).status, 422);
10001000 // The same settings on the repository's Settings → Actions page.
@@ -1785,7 +1785,7 @@
17851785 // Drafts are for writers only; a read-only token sees neither the draft nor its tag lookup.
17861786 const draft = await gh.rest.repos.createRelease({ ...R, tag_name: "v2.0.0-rc.1", name: "Next", draft: true, prerelease: true });
17871787 assert.equal(draft.data.draft, true);
1788- const reader = await ok("POST", "/diffinery/tokens", { name: "reader", scopes: ["read"] });
1788+ const reader = await ok("POST", "/diffinery/tokens", { name: "reader", scopes: ["read"] }, null);
17891789 const ro = new Octokit({ auth: reader.token, baseUrl: `${base}/api/v3`, request: { retries: 0 } });
17901790 assert.deepEqual((await ro.rest.repos.listReleases(R)).data.map((x) => x.tag_name), ["v1.0.0"]);
17911791 await assert.rejects(ro.rest.repos.getRelease({ ...R, release_id: draft.data.id }), (e) => e.status === 404);
@@ -2064,7 +2064,7 @@
20642064 assert.equal(gif.status, 422);
20652065 assert.equal((await gif.json()).message, "Use a JPEG, PNG or WebP image.");
20662066 // Read-only tokens can't change a profile.
2067- const ro = (await ok("POST", "/diffinery/tokens", { name: "ro", scopes: ["read"] })).token;
2067+ const ro = (await ok("POST", "/diffinery/tokens", { name: "ro", scopes: ["read"] }, null)).token;
20682068 assert.equal((await put("/user/avatar", pngGradient(8, 8), ro)).status, 403);
20692069
20702070 // The web form (multipart, CSRF double-submit) takes the original file and the server resizes it.
@@ -2270,7 +2270,7 @@
22702270 assert.equal((await api("POST", "/diffinery/imports", { source: "octo/app", owner, options: { nope: true }, github_api_url: apiUrl })).status, 422);
22712271 assert.equal((await api("POST", "/diffinery/imports", { source: "octo/app", owner: "nobody-here", github_api_url: apiUrl })).status, 422);
22722272 assert.equal((await api("POST", "/diffinery/imports", { source: "octo/app", owner, github_token: "wrong", github_api_url: apiUrl })).status, 422);
2273- const bot = (await ok("POST", "/diffinery/agents", { login: "import-bot" })).token.token;
2273+ const bot = (await ok("POST", "/diffinery/agents", { login: "import-bot" }, null)).token.token;
22742274 const botTry = await api("POST", "/diffinery/imports", { source: "octo/app", owner, github_token: GH_TOKEN, github_api_url: apiUrl }, bot);
22752275 assert.equal(botTry.status, 403, "agents cannot import into a person's account");
22762276
modifiedapps/diffinery/test/e2e/email.test.mjs+2−2View file
@@ -39,7 +39,7 @@
3939 async function api(method, path, body, tok = token) {
4040 const res = await fetch(`${base}/api/v3${path}`, {
4141 method,
42- headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
42+ headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" },
4343 body: body === undefined ? undefined : JSON.stringify(body),
4444 });
4545 const text = await res.text();
@@ -191,7 +191,7 @@
191191 assert.equal(sub.subscribed, true);
192192
193193 // Adding an agent is a security email.
194- const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" });
194+ const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" }, null);
195195 const botToken = bot.token.token;
196196 await email("agent added", (e) => e.kind === "agent_created");
197197
modifiedapps/diffinery/test/e2e/registry.test.mjs+2−2View file
@@ -65,7 +65,7 @@
6565 }
6666
6767 async function api(method, path, body, tok = token) {
68- const res = await fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
68+ const res = await fetch(`${base}/api/v3${path}`, { method, headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
6969 const text = await res.text();
7070 let json = null;
7171 try {
@@ -300,7 +300,7 @@
300300 });
301301
302302 test("registry: token scopes, the packages API and repository links", async () => {
303- const read = (await ok("POST", "/diffinery/tokens", { name: "pull only", scopes: ["read"] })).token;
303+ const read = (await ok("POST", "/diffinery/tokens", { name: "pull only", scopes: ["read"] }, null)).token;
304304 const name = `${owner}/scoped`;
305305 const img = await pushImage(name, "latest");
306306 assert.equal((await reg("GET", `${name}/manifests/latest`, { tok: read })).status, 200);
modifiedapps/diffinery/test/e2e/security.test.mjs+38−8View file
@@ -39,7 +39,7 @@
3939 const hash = (s) => createHash("sha256").update(s).digest("hex");
4040 const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
4141 const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") };
42-const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}` };
42+const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}` };
4343 const OWNER = "olive";
4444
4545 const freePort = () =>
@@ -133,6 +133,7 @@
133133 account("a-helper", "olive-helper", "agent", "active", "u-olive"),
134134 token("t10", "a-helper", T.helper, ["write"]),
135135 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`,
136+ session("s4", "u-sam", S.sam),
136137 ].join("; "),
137138 );
138139 dev = spawn(
@@ -408,10 +409,10 @@
408409 await status(403, "POST", "/diffinery/tokens", { name: "escalate", scopes: ["admin"] }, T.reader);
409410 await status(403, "PUT", "/notifications", {}, T.reader);
410411 // Token scopes are checked on the server.
411- await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: ["root"] });
412- await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: [] });
413- await status(422, "POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["superuser"] });
414- const minted = await ok("POST", "/diffinery/tokens", { name: "fine", scopes: ["read", "read"] });
412+ await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: ["root"] }, { cookie: S.olive });
413+ await status(422, "POST", "/diffinery/tokens", { name: "bad", scopes: [] }, { cookie: S.olive });
414+ await status(422, "POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["superuser"] }, { cookie: S.olive });
415+ const minted = await ok("POST", "/diffinery/tokens", { name: "fine", scopes: ["read", "read"] }, { cookie: S.olive });
415416 assert.ok(minted.token);
416417 assert.deepEqual((await ok("GET", "/diffinery/tokens")).find((t) => t.id === minted.id).scopes, ["read"]);
417418
@@ -528,11 +529,11 @@
528529 test("security: limited tokens, invisible repositories, readers' labels and the audit trail", async () => {
529530 await repoWithReadme("limits");
530531 // ------------------------------------------------------------ a token limited to some repositories can't mint one that isn't
531- const limited = (await ok("POST", "/diffinery/tokens", { name: "limited", scopes: ["admin"], repositories: [`${OWNER}/limits`] })).token;
532+ const limited = (await ok("POST", "/diffinery/tokens", { name: "limited", scopes: ["admin"], repositories: [`${OWNER}/limits`] }, { cookie: S.olive })).token;
532533 await status(403, "POST", "/diffinery/tokens", { name: "wider", scopes: ["admin"] }, limited);
533534 // ------------------------------------------------------------ private repositories you can't see stay unknown
534535 await ok("POST", "/user/repos", { name: "hidden", private: true }, T.rita);
535- await status(422, "POST", "/diffinery/tokens", { name: "peek", repositories: ["rita/hidden"] });
536+ await status(422, "POST", "/diffinery/tokens", { name: "peek", repositories: ["rita/hidden"] }, { cookie: S.olive });
536537 await status(404, "POST", "/repos/rita/hidden/issues", { title: "hi" }, T.reader);
537538 // An anonymous push is asked for credentials (git only sends them after a 401), even to a public repository.
538539 await ok("PATCH", `/repos/${OWNER}/limits`, { visibility: "public" });
@@ -634,7 +635,7 @@
634635 await ok("PATCH", "/orgs/shop", { default_repository_permission: "none" });
635636 await ok("POST", "/orgs/shop/repos", { name: "sam-lab", private: true }, T.sam);
636637 await ok("POST", "/orgs/shop/repos", { name: "tools", private: true });
637- const helper = (await ok("POST", "/diffinery/agents", { login: "sam-helper" }, T.sam)).agent.login;
638+ const helper = (await ok("POST", "/diffinery/agents", { login: "sam-helper" }, { cookie: S.sam })).agent.login;
638639 await status(204, "PUT", "/repos/shop/tools/collaborators/sam", { permission: "write" });
639640 await status(204, "PUT", `/repos/shop/tools/collaborators/${helper}`, { permission: "write" });
640641 assert.equal((await ok("GET", "/repos/shop/sam-lab", undefined, T.sam)).permissions.admin, true);
@@ -1185,3 +1186,32 @@
11851186 const held = await opened("held");
11861187 assert.equal(await bypassForm(held), null);
11871188 });
1189+
1190+test("security: no token can mint a token, an agent or a sandbox, through the API or the web forms", async () => {
1191+ await repoWithReadme("minting");
1192+ const limited = (await ok("POST", "/diffinery/tokens", { name: "limited-mint", scopes: ["admin"], repositories: [`${OWNER}/minting`] }, { cookie: S.olive })).token;
1193+ // A job token's shape: minting refuses job and sandbox tokens before any repository checks them.
1194+ const job = `djt_repo_job_${"0".repeat(48)}`;
1195+ const tokens = async () => (await ok("GET", "/diffinery/tokens", undefined, { cookie: S.olive })).length;
1196+ const before = await tokens();
1197+ // Admin, agent, read-only and repository-limited tokens, and job tokens: never.
1198+ for (const as of [T.olive, T.bot, T.reader, limited, job]) {
1199+ const r = await api("POST", "/diffinery/tokens", { name: "escalate", scopes: ["admin"] }, as);
1200+ assert.equal(r.status, 403, JSON.stringify(r.json));
1201+ assert.equal((await api("POST", "/diffinery/agents", { login: `mint-${randomBytes(3).toString("hex")}` }, as)).status, 403);
1202+ assert.equal((await api("POST", "/diffinery/agents/olive-bot/tokens", { scopes: ["admin"] }, as)).status, 403);
1203+ }
1204+ assert.match((await api("POST", "/diffinery/tokens", { name: "escalate" }, T.olive)).json.message, /browser session/);
1205+ // The web form with a token and a forged double-submit cookie: refused, nothing minted.
1206+ const form = (fields) => ({ method: "POST", redirect: "manual", body: new URLSearchParams({ _csrf: "forged", ...fields }), headers: { authorization: `Bearer ${T.olive}`, cookie: "dfy_csrf=forged" } });
1207+ assert.equal((await fetch(`${base}/settings/tokens`, form({ name: "escalate", scope: "admin", expires: "90" }))).status, 403);
1208+ assert.equal((await fetch(`${base}/settings/agents`, form({ login: "web-escalate" }))).status, 403);
1209+ assert.equal(await tokens(), before);
1210+ // A browser session still mints.
1211+ await ok("POST", "/diffinery/tokens", { name: "from the browser", scopes: ["read"] }, { cookie: S.olive });
1212+ assert.equal(await tokens(), before + 1);
1213+ // A repository-limited token can't create repositories through an import either.
1214+ await status(403, "POST", "/diffinery/imports", { source: "octo/hello", owner: OWNER }, limited);
1215+ // Job and sandbox tokens can't start sandboxes, whose tokens would outlive them.
1216+ await status(403, "POST", `/repos/${OWNER}/minting/diffinery/sandboxes`, {}, job);
1217+});