Skip to content

feat(server): refuse workspace creation without may_create_workspaces consent (IDEA-2756) - #1212

Merged
xarmian merged 7 commits into
mainfrom
fix/idea-2756-refuse-create-without-consent
Aug 26, 2026
Merged

feat(server): refuse workspace creation without may_create_workspaces consent (IDEA-2756)#1212
xarmian merged 7 commits into
mainfrom
fix/idea-2756-refuse-create-without-consent

Conversation

@xarmian

@xarmian xarmian commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Closes IDEA-2756.

The defect

The OAuth consent screen carries a checkbox labelled "Let this app create new workspaces". It gated only maybeAutoAddCreatorConnection's allow-list insert — so a connection whose user left it unticked could still create workspaces through POST /api/v1/workspaces; it simply could not then see what it made. A permission that does not prevent the action it names is a consent mismatch, and the user's natural reading of an unticked box is "this app cannot make workspaces in my account".

Ruled by Dave (day-51, on IDEA-2756's trail): the checkbox is a permission on whether the connected token has the right to CREATE, and it has to be true to what a user would honestly expect from the option. The behaviour-change-for-existing-connections argument loses to honest consent semantics.

This is not, and never was, a security finding. Creation cannot disclose anything — it mints a new workspace rather than reading an existing one. It is a consent-honesty fix.

The population, enumerated before the fix

The item's disposition named one site. The class is endpoints that MINT a workspace under the caller's account and are reachable by an OAuth-connection token. Enumerated from all five non-test CreateWorkspace( call sites in internal/ and cmd/, then traced to routes:

site in? why
POST /api/v1/workspaceshandleCreateWorkspace gated the named instance. MCP pad_workspace action=create and both CLI paths (pad init, pad workspace init) POST here — one guard, three surfaces
POST /api/v1/workspaces/importhandleImportWorkspace gated, not currently reachable reaches CreateWorkspace via store.ImportWorkspace. The gzip branch dispatches to handleImportWorkspaceBundle from inside this handler, and that handler has no other route, so one guard at the top covers both request bodies. See the reachability note below — no OAuth caller can reach this route today
autoCreateWorkspace (handlers_cloud.go), reached from /auth/bootstrap, /auth/register, /auth/oauth-login out signup-time, cloud-only; no OAuth connection in context — this is the user provisioning their own first workspace
POST /workspaces/{slug}/restore out un-deletes a workspace the user already owns — not minting
POST /oauth/claim out grants access to an existing workspace
cmd_db.go ImportWorkspace out local store copy, no HTTP, no token

Import was lead-ruled in as an application of Dave's stated rationale rather than a new decision: a user who left the box unticked does not expect the app to mint a workspace by importing one.

Reachability, stated precisely — a correction to my own first framing

I originally wrote that both endpoints are "reachable by an OAuth-connection token". That is true of create and false of import, and I found it on my own verify pass after opening this PR; Codex round 2 reached the same conclusion independently.

WithMCPTokenIdentity is stashed by exactly one middleware, MCPBearerAuth, mounted on /mcp alone. An OAuth connection therefore reaches an /api/v1 handler only through the in-process MCP dispatcher — and that dispatcher's route table has a workspace create action but no workspace import.

So:

  • the create gate is live, and covers the whole population that can reach it (MCP pad_workspace.create; pad init and pad workspace init POST to the same endpoint under ordinary account authority and take the non-OAuth branch)
  • the import gate is correct but currently unexercised in production. It is here so that adding a workspace import MCP action later cannot silently reopen the door — which is precisely the state a create-only fix would have left armed

Worth being explicit that the new tests could not have caught this: they synthesize the OAuth identity into the request context, so they prove the handler's behaviour given an identity and have no opinion about which routes ever supply one. That is the wiring-is-a-claim blind spot (CONVE-19), and the answer here came from reading the mount points, not from a green suite.

Search boundary: the sweep traced Store.CreateWorkspace callers. It did not search for a path that inserts a workspace by raw SQL; if one exists it is outside what this enumeration could find.

The change

One shared helper, Server.requireWorkspaceCreationConsent, called at the top of both handlers. Shape mirrors handleAuditLog's consent refusal (BUG-2102): a hard 403 rather than a narrowed response, because there is no narrower version of creating a workspace.

Three non-refusal cases and one refusal:

  • not an OAuth grant (PAT, CLI session, local stdio — no request_id) → creation rides on ordinary account authority; this flag has no opinion
  • ErrOAuthConnectionNotFound (pre-Phase-C grant, not yet backfilled) → allow. The backfill mints those rows with the flag ON, so allowing is that default applied early. Deliberately asymmetric with maybeAutoAddCreatorConnection's not-found branch: that path declines a convenience, this one would invent a refusal
  • flag set → proceeds; the auto-add downstream is untouched
  • a store I/O errorrefused, failing closed with a 500. Allowing the create when the deciding state could not be read grants a declined permission on the strength of a database blip. Not stored_state_unreadable — the state is not unreadable-in-principle; the read failed, the fault is ours, and a retry can legitimately succeed

The gate sits above the body decode on both handlers, so a refusal never depends on body validity — and on import, above the 64 MiB read, so a refused caller never uploads a bundle.

No escape-hatch parameter, deliberately (contrast v0.10's allow_draft): the gate expresses a decision the user made at consent time, so a bypass flag would be the app overriding its own grant. The remedy is re-authorization, which only the user can perform.

Tests — 10 new, all through the real router

internal/server/handlers_workspace_create_consent_test.go drives srv.ServeHTTP, so the tests have an opinion about the ROUTE and not only the function (CONVE-19).

Every refusal leg asserts the wrong behaviour's observable consequence — that no workspace of that name exists afterwards — not merely the status code (CONVE-12). A guard that 403s after store.CreateWorkspace passes a status-only assertion while leaving the workspace behind.

Both control directions are covered: flag-set still creates and auto-adds; PAT callers are unaffected on both endpoints. Without those, a guard that refused every OAuth caller — or every caller — would pass all the refusal legs.

Mutation matrix — 7 mutants, 7 detected

Harness asserts anchor uniqueness, requires each mutant to compile before its run counts as evidence, requires --- FAIL: <name> for every predicted test, and verifies the restore byte-for-byte.

mutant detected by
M1 create-side guard removed 3 create refusal legs + the message leg
M2 import-side guard removed both import refusal legs
M3 create guard moved below body+name validation the 2 ordering legs, and only those
M4 not-found treated as refusal the pre-Phase-C leg
M5 flag never consulted both flag=true control legs
M6 non-OAuth early return dropped both PAT control legs
M7 import guard moved below the Content-Type dispatch the bundle leg

The uniqueness assertion earned itself twice: on the first run M5's anchor matched two functions (the guard and maybeAutoAddCreatorConnection's own check), and on the re-run after a message edit it matched zero, which is the state that would otherwise report as a clean matrix.

Not covered, stated rather than implied: the I/O-error branch has no test. Injecting a store read failure needs a fault-injecting store the package does not have, so that branch is reasoned, not measured.

MCP surface: v0.25 → v0.26

pad_workspace.action=create now refuses a call it used to permit. BEHAVIOR bump on the v0.9 / v0.16 / v0.25 grounds — no tool name, action enum, or param shape changed. Closest precedent is v0.10, which likewise turned a server-side gate into a structured refusal.

tool_surface_drift_test.go caught instructions.md and README.md immediately; both updated.

CONVE-23 sweep — prose this change falsified

Grepped the concept (may_create, auto-add, creation power) across .go, .md, .svelte, .ts, .html, then re-read each site after editing:

  1. internal/mcp/instructions.md — said "if that scope was declined the create call still succeeds but the workspace doesn't auto-join — direct the user to the claim flow". False in both halves, and it is the artifact an MCP agent reads: it would have sent the agent to claim a workspace that was never created. Rewritten to say the call is refused, retrying won't help, and name the two real remedies.
  2. internal/mcp/dispatch_http_allowlist_guard_test.go — TASK-2753's workspace create guard entry asserted "with it unset the create still succeeds" and posed IDEA-2756 as the open question. Now answered; classification stays exempt, exactly as the item predicted, because refusing to create is still not a disclosure question.
  3. internal/mcp/catalog_workspace.go and 4. cmd/pad/cmd_workspace.go — both described only the flag=true path. Not false, but incomplete in the direction that matters to an agent deciding whether to retry.
  4. internal/mcp/dispatch_http_routes.go — the OAuth identity now decides the call, not just the side effect.
  5. maybeAutoAddCreatorConnection's flag-off branch is now unreachable from its sole caller. Kept, with a comment saying so: the function's contract is "add only when the grant permits", and a helper whose safety depends on its caller checking first is one refactor from being wrong. Documented as dead code to be deleted with the guard, not as redundant defence in depth.

Checked and deliberately unchanged: the consent screen copy and the console toggle. Both were the misleading half of this bug — the fix makes existing copy true rather than needing new copy. The refusal message quotes that label verbatim, with a comment binding the two.

Found in passing and fixed: CLAUDE.md still described the catalog as v0.24 in three places — v0.25 (TASK-2657) bumped the constant without it, and nothing enforces CLAUDE.md the way the drift test enforces the other two documents. Brought to v0.26 with a backfilled v0.25 line. The drift test's coverage gap is worth its own item.

Deploy note, and one review finding declined on technical grounds

Codex round 6 raised as P1 that a mixed fleet is not policy-consistent: during a rolling deploy, old instances still answer 201 for a flag-off connection while new ones answer 403, so the same call succeeds or fails by load-balancer routing, and tool_surface_version reads 0.25 or 0.26 per instance. It suggested draining old instances before exposing the new behaviour, and noted the commit leaves no marker or cleanup path for workspaces the old instances created.

Declined as a blocker, recorded so it is not re-litigated:

  • The inconsistency window is a property of deploying any behaviour change to a fleet, not of this diff. Every prior ToolSurfaceVersion bump had the same per-instance split.

  • The failure mode inside the window is the old behaviour — a workspace gets created that the new gate would refuse. That is what happens today, all the time, and has for months.

    Corrected after round 7 argued against this, and it was right: my original wording said the window "does not create a new harm", which overstates. This is an authorization change, so during the window old nodes honour a permission the user explicitly declined, and a refused caller can simply retry until it lands on one. That is a different shape from passive uneven arrival — it is briefly bypassable by anything motivated to bypass it. The conclusion stands (what a bypass yields is exactly what the app could do freely until this PR, over a window measured in minutes), but the reasoning had to be fixed, not just restated.

  • No schema, no migration, so rollback is clean. Rollback reopening creation is what rollback means here.

  • A cleanup path for workspaces created under the old behaviour is deliberately not in scope. Those are real workspaces the user can see, owned by them, some presumably wanted. Deleting or marking them would be a destructive data operation nobody asked for, decided by an agent, on the strength of a consent flag's state at some past moment we did not record.

What is worth acting on, upgraded from round 7's argument: this is an authorization change with no schema component. Deploy it as a fast rollout rather than a long canary, and prefer draining old instances to running a mixed fleet for an extended period — not because the window is dangerous, but because "an enforcement gap you can retry into" is worth minutes rather than hours. There is nothing to sequence and no migration to order against.

Adjacent defects found and filed, not folded in

Three preconditions/side-effects now differ between the two doors onto CreateWorkspace. This PR closes the first; the other two are filed rather than folded, because each needs a decision this PR's ruling does not cover:

create import
OAuth consent gate yes yes — this PR
workspaces plan limit yes no — BUG-2793 (found by enumerating what else runs before CreateWorkspace)
auto-add to the connection's allow-list yes no — BUG-2794 (found by Codex round 7)

Three is a pattern rather than a coincidence: handleImportWorkspace was written as "restore a workspace from an artifact" and handleCreateWorkspace as "mint a workspace for this caller", so everything the second learns about being a caller-facing creation endpoint has to be re-learned by the first, one incident at a time. Both filed items argue for a shared pre/post step over a third one-off patch.

Also filed: BUG-2792, the residual TOCTOU window inside the auto-add itself.

Gates

  • make test (SQLite): 28 packages ok, 0 failures, exit 0
  • Full suite against Postgres 17 on a private container (port 5462, not the shared 5445 — a sibling seat is live): 28 packages ok, 0 failures, exit 0. internal/store took 351s vs 76s on SQLite, so the PG legs demonstrably ran rather than skipping
  • make lint: 0 issues
  • make vuln: no vulnerabilities affecting this code

… consent (IDEA-2756)

The OAuth consent screen's "Let this app create new workspaces" checkbox
gated only the post-creation auto-add. A connection whose user left it
unticked could still create workspaces; it simply could not then see
them. A permission that does not prevent the action it names is a
consent mismatch.

Dave ruled it: the checkbox is a permission on whether the connected
token may CREATE, and it has to be true to what a user would honestly
expect from the option. The behaviour-change-for-existing-connections
argument loses to honest consent semantics.

Adds Server.requireWorkspaceCreationConsent, a shared gate at the top of
both endpoints that mint a workspace under the caller's account:

  POST /api/v1/workspaces         handleCreateWorkspace
  POST /api/v1/workspaces/import  handleImportWorkspace

Import reaches CreateWorkspace via store.ImportWorkspace, so it is the
same permission at a second door — lead-ruled as an application of the
same rationale, not a new decision. The gate sits above the Content-Type
dispatch, so it covers the tar.gz bundle path (whose only route is that
handler) and refuses before the 64 MiB body read.

Refusal is a 403, mirroring handleAuditLog's consent refusal (BUG-2102):
a hard decline rather than a narrowed response, because there is no
narrower version of creating a workspace.

Three non-refusal cases and one refusal, all but the last with a test:

  - not an OAuth grant (PAT, CLI session, local stdio) — creation rides
    on ordinary account authority
  - ErrOAuthConnectionNotFound (pre-Phase-C grant) — ALLOW, matching the
    backfill's may_create_workspaces=ON default. Deliberately asymmetric
    with maybeAutoAddCreatorConnection's not-found branch, which declines
    a convenience where this one would invent a refusal
  - flag set — proceeds; the auto-add is unchanged
  - a store I/O error — REFUSED, failing closed with a 500, because
    allowing the create when the deciding state could not be read grants
    a declined permission on the strength of a database blip. This is
    the one branch with no test: injecting a store read failure needs a
    fault-injecting store the package does not have, so it is reasoned
    rather than measured

Population enumerated before the fix (CONVE-18): five CreateWorkspace
call sites, two of them HTTP endpoints reachable by an OAuth token (both
gated). Excluded with reasons: autoCreateWorkspace (signup-time, no
connection in context), workspace restore (un-deletes an existing
workspace), /oauth/claim (grants access, does not mint), cmd_db.go
(local store copy, no HTTP). Search boundary: the sweep traced
Store.CreateWorkspace callers and did not look for a path that inserts a
workspace by raw SQL.

Ten tests, all driving the real router rather than calling handlers
directly (CONVE-19). Every refusal leg asserts that no workspace of that
name exists afterwards, not merely the status code (CONVE-12) — a guard
that 403s after the write passes a status-only assertion. Seven mutants,
seven detected, including both guard-placement mutations.

MCP tool surface 0.25 -> 0.26. Behaviour bump on the v0.9/v0.16/v0.25
grounds: no tool name, action enum or param shape changed, but
pad_workspace.create now refuses a call it used to permit. Closest
precedent is v0.10; unlike v0.10 there is deliberately no escape-hatch
param, because the gate encodes a decision the USER made at consent time
and a bypass flag would be the app overriding its own grant.

CONVE-23 sweep for prose the change falsified: instructions.md told
agents the create still succeeds and to use the claim flow (it would
have sent them to claim something that was never created); the
TASK-2753 allow-list guard entry asserted the same and posed IDEA-2756
as open; the MCP catalog and CLI help described only the flag=true path;
maybeAutoAddCreatorConnection's flag-off branch is now unreachable from
its sole caller and is documented as dead code kept for contract, to be
deleted only with the guard. CLAUDE.md was already stale at v0.24 (v0.25
bumped the constant without it) — brought to v0.26 with a backfilled
v0.25 line.

The consent screen and console copy are unchanged: they were the
misleading half of this bug, and the fix makes them true.
The import-side gate is correct but currently unexercised in production,
and the first framing of this change did not say so.

WithMCPTokenIdentity is stashed by exactly one middleware, MCPBearerAuth,
mounted on /mcp alone. An OAuth connection reaches an /api/v1 handler
only through the in-process MCP dispatcher, and that dispatcher's route
table has a workspace create action but no workspace import. So no
OAuth-bound caller can reach handleImportWorkspace today.

The gate stays, and the comment now says why: adding that action later
must not silently reopen the door, which is the state a create-only fix
would have left armed.

Found on a verify pass reading the middleware mount points, not by the
tests — they synthesize the OAuth identity into the request context, so
they prove the handler's behaviour GIVEN an identity and have no opinion
about which routes supply one (CONVE-19). Codex round 2 reached the same
conclusion independently.
…2756)

All five were mine, all P2, none changing the gate's behaviour — four are
claims that were broader than the code, one is a test that proved less
than its name.

1. "Only re-authorization lifts it" was wrong in five places (version.go,
   README, CLAUDE.md, the MCP catalog description, CLI help). A user can
   also enable the flag on the EXISTING connection via
   PATCH /connected-apps/{id}/flags, which the console page drives —
   instructions.md said so and contradicted the others. All five now name
   both remedies, and both are still the user's, which is the part that
   matters: neither is reachable by the app.

2. "This branch is UNREACHABLE ... it is dead code" on
   maybeAutoAddCreatorConnection's flag-off branch was false. The gate
   reads the connection and that function reads it AGAIN after creation;
   a user revoking creation power from the console between those two
   reads lands exactly there. It is a real second check across a real
   TOCTOU window, failing in the safe direction. The claim was written
   from the call graph, which cannot see a concurrent write between two
   reads.

3. handlers_import_bundle.go's "Auth: any authenticated user" was made
   false by this change and the concept sweep never had a chance at it —
   it greps may_create / auto-add / creation power, and that sentence
   contains none of them. Corrected in place.

4. The two NonOAuthCallerUnaffected tests claimed PAT, CLI session and
   local stdio; each drives one PAT. The comments now state the fixture's
   real scope and why one caller stands for the class (the guard branches
   on an identity only MCPBearerAuth sets, so callers that skipped it are
   indistinguishable) rather than implying three fixtures.

5. The JSON import refusal leg would have passed with the gate below
   decodeJSONWithLimit — only the bundle leg pinned placement, and only
   for gzip. Adds TestImportWorkspace_ConsentRefusalPrecedesBodyDecode
   (malformed body: 400 if the gate is late, 403 if it is early),
   mirroring the create-side ordering legs.

Mutation matrix now 9 mutants, 9 detected. M8 (guard below the JSON
decode) is killed by the bundle leg too, so it shows the new test is
covered rather than necessary; M9 gates the bundle path and moves only
the JSON path's guard, and dies to the new leg ALONE. That is the mutant
that justifies the test.
…close it (BUG-2792)

Round 3 caught me calling maybeAutoAddCreatorConnection's flag-off
branch dead code. The replacement comment then claimed the branch means
a revoked grant cannot silently gain a workspace — which is more safety
than the code delivers, and round 4 caught that.

The read and the AddConnectionWorkspace insert below it are separate
unconditional statements, so a revocation landing BETWEEN them still
adds the workspace. The check narrows the window; it does not close it.

Filed as BUG-2792 rather than folded in: the race is pre-existing and
unchanged by IDEA-2756, and closing it needs an atomic check-and-insert
at the store layer, written and gated for both dialects — materially
more diff and risk than this handler-level guard.

Both mistakes were the same shape in opposite directions: a claim about
concurrency derived from reading the call graph, which cannot see a
concurrent write between two reads.
gofmt wants blank lines between list items once one item spans multiple
paragraphs, which the BUG-2792 note made true.

My error, and worth naming exactly: I ran build, vet and the targeted
tests on this commit but not lint, because lint had passed on the
PREVIOUS commit and the change was 'only a comment'. The gate has to run
on the tree being pushed, not on an earlier one that resembles it. CI's
golangci-lint is pinned to the same v2.11.4 the Makefile installs, so
there was no version skew to blame — the local gate would have caught
this in 51 seconds.
…(IDEA-2756)

Round 8 reviewed only the prose this change adds. Ten claims were
broader than the code. All ten are mine; none changes behaviour. Rounds
3, 4 and 7 each caught one of these, which is why round 8 was pointed at
the class rather than at a new dimension.

The substantive ones:

- "gates every endpoint that MINTS a workspace" — autoCreateWorkspace
  mints from registration, bootstrap and oauth-login and is deliberately
  outside this gate. The helper doc and the test header now name the two
  callers and the exclusion instead of claiming universality.

- "the agent was handed a workspace it could not then see" (version.go,
  README, CLAUDE.md) — only true for a connection with an EXPLICIT
  allow-list. An all_current_workspaces=true connection is not gated per
  slug and could see what it made. The consent mismatch is the constant;
  the invisibility was its most visible symptom, not its definition.

- "ErrOAuthConnectionNotFound — a pre-Phase-C grant" asserted a cause the
  code cannot know: ANY missing row takes that branch. Now stated as the
  expected cause, with the limit of what the code can tell.

- "above the 64 MiB body read" conflated the two import paths. 64 MiB is
  the JSON decode's bound; the bundle path has its own, much larger. The
  gate precedes both, which is the property that actually matters.

- "the request context is decorated AFTER TokenAuth runs" was false, and
  inherited verbatim from the sibling helper this was modelled on
  (handlers_oauth_claim_test.go's doClaim), where it is also false. The
  wrapper sets the identity BEFORE ServeHTTP; it survives because
  nothing on the /api/v1 chain writes that key.

- "lets CreateWorkspace normalize it" — CreateWorkspace slugifies only
  when the supplied slug is EMPTY, and import supplies a non-empty one,
  so an imported workspace keeps the ?name= value verbatim.

- "The PAT needs a workspace to bind to" — CreateAPIToken takes
  WorkspaceID as optional.

And one where the first fix was worse than the finding:

- "Every refusal leg asserts no workspace exists afterwards" was false —
  the two ordering legs assert status only. My first correction ADDED
  those assertions, which is the trap the finding was pointing at: a
  malformed body and an empty name are rejected before creation under
  every guard placement, so "no such workspace exists" is true of broken
  and working code alike. Reverted; the header now states which legs
  carry the counterfactual, and why the ordering legs discriminate on
  status instead.

Gates re-run on the tree being pushed, not an earlier one: gofmt clean,
lint 0 issues, internal/server and internal/mcp green, mutation matrix
still 9/9.
No code change. The Go job on cb47c76 failed on BUG-2786 (the recurring
internal/events subscribe-confirm guard, which fails by asserting its own
premise: 'the acknowledgement never landed before the mark; this test could
not have discriminated'). CONVE-11 owes that failure a re-run before it can
be called a flake.

rerun-failed-jobs produced attempt 2 = startup_failure with the Go job stuck
in 'queued' — a GitHub infrastructure fault, not a test result — after which
the run refuses further retries ('This workflow run cannot be retried'). The
CI workflow has no workflow_dispatch trigger, so a push is the only way to
get a fresh run.

Evidence the failure is unrelated to this branch, gathered before re-running
rather than after: the branch touches 0 files under internal/events (11 files
total, none in that package), and the parent tip c681850 had Go: SUCCESS with
the only non-comment Go difference being one added assertion in this branch's
own test file. Go (PostgreSQL) also passed on cb47c76, exercising the same
package.
@xarmian
xarmian marked this pull request as ready for review August 26, 2026 17:23
@xarmian
xarmian merged commit 91d92f1 into main Aug 26, 2026
6 of 7 checks passed
@xarmian
xarmian deleted the fix/idea-2756-refuse-create-without-consent branch August 26, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant