Skip to content

Tool grant tokens can mint fresh grants, making revocation and expiry non-terminal #578

Description

@kcarnold

Summary

POST /api/handoff authenticates with resolveUser, and resolveUser accepts wtk_ tool grant tokens. So a tool holding a grant token can call the mint endpoint itself and issue a new grant — for any allowlisted tool_client_id, with any scopes, carrying a document snapshot of its own choosing — then exchange it for a fresh token under the same user.

Nothing here is exploitable by an unauthenticated party. It requires a tool the user already launched and already granted access to. Every tool today is first-party, so this is latent. It becomes load-bearing the moment we point the launcher at tools we don't write, which is the direction discussed in #569 review.

The chain

All line numbers on main @ 43baf2a.

  1. backend/src/app.ts:370app.post('/api/handoff', ...) calls resolveUser(c) at line 371.
  2. backend/src/app.ts:194-196resolveUser returns the grant's user for any bearer with a wtk_ prefix. It cannot distinguish "the signed-in taskpane" from "a tool holding a grant token"; both are just an authenticated SessionUser.
  3. backend/src/app.ts:383 — the only check on the request is deviceClientIds().includes(toolClientId). Scopes are validated for membership in TOOL_SCOPES but never checked against what the caller holds, and doc is taken from the request body as-is.
  4. backend/src/app.ts:421/api/handoff/exchange is unauthenticated by design ("the grant_id itself is the credential"), so the same tool can redeem the grant it just minted.

Result: a wtk_ token in, a different wtk_ token out, no user interaction anywhere in the loop.

Why it matters

Revocation and expiry stop being terminal. TOKEN_TTL_MS is 1 hour (toolGrants.ts:61), and POST /api/handoff/revoke disconnects one token. A tool can chain a replacement before either lands and keep doing so indefinitely. The user's only reliable lever becomes deleting the underlying account. This is the part I'd call an actual defect rather than a hardening item — the Tools page presents "Disconnect" as a real control, and it is only as strong as the tool's willingness to honour it.

Attribution can be laundered. tool_client_id is authoritative from the grant (toolGrants.ts:204) and flows into llm_usage.client_id and study-log rows. A tool can name a different allowlisted tool and have its model spend and log lines booked there. headerClientId (app.ts:55-58) goes out of its way to stop exactly this on the X-Client-Id path — "so an arbitrary label can't be injected into billing/study data" — and this route reopens it.

Scope escalation is currently harmless but won't stay that way. A tool granted only openai:chat can mint itself doc:read and doc:write. Today the only enforced scope is doc:read on GET /api/handoff/doc, which returns the snapshot stored on that grant — which the escalating tool supplied itself, so it learns nothing. Phase 3 scope enforcement (see the note on TOOL_SCOPES, toolGrants.ts:30-35) is what turns this into a real escalation, so it should be closed before that lands, not after.

Possible directions

Not a recommendation, just the options as I see them:

  • Mark the identity as tool-derived and refuse POST /api/handoff for it. Cleanest, and matches the intent: minting is a thing the signed-in taskpane does. ProxyIdentity in openaiProxy.ts already distinguishes credential kinds for the proxy; the same idea applied to resolveUser would cover this and any other route that should be taskpane-only.
  • Keep minting open but require the new grant's scopes to be a subset of the caller's, and force tool_client_id to match the caller's own.
  • Chain-count or lineage-cap grants so a token cannot renew itself past some horizon without a fresh user-initiated launch.

Context

Came out of reviewing #569 / #575. The resolveUser-accepts-wtk_ behaviour predates both; #569 did not introduce it. Related: the tool allowlist lives in the BETTER_AUTH_DEVICE_CLIENT_IDS env var, so it needs a backend redeploy per tool — a separate scaling problem for third-party tools, not filed yet.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions