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.
backend/src/app.ts:370 — app.post('/api/handoff', ...) calls resolveUser(c) at line 371.
backend/src/app.ts:194-196 — resolveUser 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.
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.
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.
Summary
POST /api/handoffauthenticates withresolveUser, andresolveUseracceptswtk_tool grant tokens. So a tool holding a grant token can call the mint endpoint itself and issue a new grant — for any allowlistedtool_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.backend/src/app.ts:370—app.post('/api/handoff', ...)callsresolveUser(c)at line 371.backend/src/app.ts:194-196—resolveUserreturns the grant's user for any bearer with awtk_prefix. It cannot distinguish "the signed-in taskpane" from "a tool holding a grant token"; both are just an authenticatedSessionUser.backend/src/app.ts:383— the only check on the request isdeviceClientIds().includes(toolClientId). Scopes are validated for membership inTOOL_SCOPESbut never checked against what the caller holds, anddocis taken from the request body as-is.backend/src/app.ts:421—/api/handoff/exchangeis 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 differentwtk_token out, no user interaction anywhere in the loop.Why it matters
Revocation and expiry stop being terminal.
TOKEN_TTL_MSis 1 hour (toolGrants.ts:61), andPOST /api/handoff/revokedisconnects 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_idis authoritative from the grant (toolGrants.ts:204) and flows intollm_usage.client_idand 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 theX-Client-Idpath — "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:chatcan mint itselfdoc:readanddoc:write. Today the only enforced scope isdoc:readonGET /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 onTOOL_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:
POST /api/handofffor it. Cleanest, and matches the intent: minting is a thing the signed-in taskpane does.ProxyIdentityinopenaiProxy.tsalready distinguishes credential kinds for the proxy; the same idea applied toresolveUserwould cover this and any other route that should be taskpane-only.tool_client_idto match the caller's own.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 theBETTER_AUTH_DEVICE_CLIENT_IDSenv var, so it needs a backend redeploy per tool — a separate scaling problem for third-party tools, not filed yet.