The shared clanker identity consumer-clanker-${botId}-${orgId} was trusted blindly at tool-router session creation. Both components are visible to ordinary org members:
botId via GET /api/v3/team-members/list (returns users.id for every member, clankers included)orgId via any authenticated responseSo the string is forgeable: any org member holding a project API key for the org's consumer project can mint a tool-router session bound to the clanker identity and execute against its PRIVATE connected accounts — sending email from the clanker's Gmail, posting to its Slack, opening PRs as it on GitHub, etc.
The /api/v3/org/consumer/project/resolve gate is scoped to issuance and assumed it was the only path to the string. It is not.
lib/consumer/validate_clanker_user_id.ts (new) — re-derives the clanker relationship from the authenticated principal at use-time. Fast path: if user_id doesn't match consumer-clanker-{botId}-{authedOrgId}, no DB hit, no behavior change. If it does match, calls the existing resolveClankerBotIdForCaller and compares — caller must be the clanker itself or its registered humanOwnerId. Otherwise → 403.pages/api/v3/tool_router/session.ts — wired the validator into registerCreateSession after auth resolution, before getOrCreateToolRouterSession. Single edit covers v3, v3.1, and the admin variant since they all flow through registerCreateSession.getConsumerUserId — untouched.session.config.userId value for any passing request — caller's user_id verbatim, same as before.getOrFindConnectedAccount / getAvailableToolkits — unchanged.Still vulnerable to the same trust pattern (separate follow-ups):
POST /api/v3/tools/execute/{slug} with caller-supplied entity_id) — same matching layer, same fix shape required.consumer-{userId}-{orgId} identity within a non-clanker consumer project. Closing this requires server-side derivation rather than accepting caller input, which is a separate behavior change.lib/consumer/validate_clanker_user_id.test.ts — 9 unit tests using the repo's standard vi.hoisted + vi.mock pattern (mirrors auth_middleware_bearer_disabled.test.ts):
user_id → passes, no DB hitconsumer-${userId}-${orgId} → passes, no DB hitnpx oxlint on changed files: 0 warnings, 0 errorspnpm check-types on changed files: 0 errors (repo has pre-existing typecheck failures in unrelated files — missing @/src/generated/thermos artifact and pre-existing unknown-type issues — not introduced by this PR)Before merging, check Datadog APM for POST /api/v3/tool_router/session (and /api/v3.1/tool_router/session) requests with body.user_id LIKE 'consumer-clanker-%'. Confirm every such call comes from a project API key whose owning orgMember.id is either the bot user or its humanOwnerId. If any legitimate caller falls outside that, this patch will start rejecting it (403), and the guard needs to be reshaped to resolve the bot relationship from the project's consumer config instead of from the caller's auth context.