diff --git a/packages/api/src/auth/enforce-identity.ts b/packages/api/src/auth/enforce-identity.ts new file mode 100644 index 00000000..5647668c --- /dev/null +++ b/packages/api/src/auth/enforce-identity.ts @@ -0,0 +1,42 @@ +/** + * Identity Enforcement Utility + * + * Returns the effective agentId for WRITE operations, enforcing identity + * pinning when enabled. Read/query operations should NOT use this — they + * need to freely specify agentId as a filter parameter. + * + * Feature flag: ENFORCE_IDENTITY_PINNING (env var, default: 'true') + * 'true' — pinned identity overrides explicit agentId on writes + * 'false' — logs warnings but allows explicit agentId (warn-only mode) + */ + +import { getPinnedAgentId } from '../utils/request-context'; +import { env } from '../config/env'; +import { logger } from '../utils/logger'; + +/** + * Returns the effective agentId for a write operation. + * + * - If identity is pinned (via bootstrap or token), returns the pinned value + * (or the explicit value if enforcement is disabled via feature flag). + * - If no identity is pinned (human user, pre-bootstrap), returns the explicit value. + */ +export function getEffectiveAgentId(explicitAgentId?: string): string | undefined { + const pinned = getPinnedAgentId(); + if (!pinned) return explicitAgentId; + + if (explicitAgentId && explicitAgentId !== pinned) { + const enforced = env.ENFORCE_IDENTITY_PINNING !== 'false'; + logger.warn('Agent identity mismatch detected', { + claimed: explicitAgentId, + authenticated: pinned, + enforced, + }); + + if (!enforced) { + return explicitAgentId; // Feature flag off: warn but allow + } + } + + return pinned; +} diff --git a/packages/api/src/auth/pcp-tokens.ts b/packages/api/src/auth/pcp-tokens.ts index 48761582..128ad9c9 100644 --- a/packages/api/src/auth/pcp-tokens.ts +++ b/packages/api/src/auth/pcp-tokens.ts @@ -22,6 +22,8 @@ export interface PcpTokenPayload { sub: string; // PCP user ID email: string; scope: string; + agentId?: string; // Bound agent identity label (absent for human users) + identityId?: string; // Canonical agent_identities UUID (strongest binding) } // ============================================================================ @@ -81,7 +83,9 @@ export async function createRefreshToken( userId: string, clientId: string, scopes: string[], - lifetimeDays: number + lifetimeDays: number, + agentId?: string, + identityId?: string ): Promise<{ refreshToken: string; expiresAt: Date }> { const refreshToken = `pcp-rt-${crypto.randomBytes(32).toString('hex')}`; const expiresAt = new Date(Date.now() + lifetimeDays * 24 * 60 * 60 * 1000); @@ -93,6 +97,8 @@ export async function createRefreshToken( supabase_refresh_token: null, scopes, expires_at: expiresAt.toISOString(), + ...(agentId ? { agent_id: agentId } : {}), + ...(identityId ? { identity_id: identityId } : {}), }); if (error) { @@ -115,7 +121,13 @@ export async function exchangeRefreshToken( clientId: string, tokenType: PcpTokenPayload['type'], accessTokenLifetimeSeconds: number -): Promise<{ accessToken: string; userId: string; email: string } | null> { +): Promise<{ + accessToken: string; + userId: string; + email: string; + agentId?: string; + identityId?: string; +} | null> { const { data: tokenRecord, error: lookupError } = await supabase .from('mcp_tokens') .select('*, users(email)') @@ -144,6 +156,9 @@ export async function exchangeRefreshToken( const userEmail = (tokenRecord.users as unknown as { email: string | null })?.email || ''; const scope = tokenRecord.scopes?.join(' ') || 'mcp:tools'; + const tokenAny = tokenRecord as Record; + const agentId = tokenAny.agent_id as string | null; + const identityId = tokenAny.identity_id as string | null; const accessToken = signPcpAccessToken( { @@ -151,6 +166,8 @@ export async function exchangeRefreshToken( sub: tokenRecord.user_id, email: userEmail, scope, + ...(agentId ? { agentId } : {}), + ...(identityId ? { identityId } : {}), }, accessTokenLifetimeSeconds ); @@ -165,5 +182,7 @@ export async function exchangeRefreshToken( accessToken, userId: tokenRecord.user_id, email: userEmail, + ...(agentId ? { agentId } : {}), + ...(identityId ? { identityId } : {}), }; } diff --git a/packages/api/src/config/env.ts b/packages/api/src/config/env.ts index 41bc0c3a..a100d8f5 100644 --- a/packages/api/src/config/env.ts +++ b/packages/api/src/config/env.ts @@ -87,6 +87,9 @@ const envSchema = z.object({ DISCORD_BOT_TOKEN: optionalString, DISCORD_APPLICATION_ID: optionalString, + // Identity enforcement + ENFORCE_IDENTITY_PINNING: z.enum(['true', 'false']).default('true'), + // OAuth - Google GOOGLE_CLIENT_ID: optionalString, GOOGLE_CLIENT_SECRET: optionalString, diff --git a/packages/api/src/data/supabase/types.ts b/packages/api/src/data/supabase/types.ts index d2b0e2a3..f816216b 100644 --- a/packages/api/src/data/supabase/types.ts +++ b/packages/api/src/data/supabase/types.ts @@ -1287,10 +1287,12 @@ export type Database = { }; mcp_tokens: { Row: { + agent_id: string | null; client_id: string; created_at: string | null; expires_at: string; id: string; + identity_id: string | null; last_used_at: string | null; refresh_token: string; scopes: string[] | null; @@ -1299,10 +1301,12 @@ export type Database = { user_id: string; }; Insert: { + agent_id?: string | null; client_id: string; created_at?: string | null; expires_at: string; id?: string; + identity_id?: string | null; last_used_at?: string | null; refresh_token: string; scopes?: string[] | null; @@ -1311,10 +1315,12 @@ export type Database = { user_id: string; }; Update: { + agent_id?: string | null; client_id?: string; created_at?: string | null; expires_at?: string; id?: string; + identity_id?: string | null; last_used_at?: string | null; refresh_token?: string; scopes?: string[] | null; @@ -1323,6 +1329,13 @@ export type Database = { user_id?: string; }; Relationships: [ + { + foreignKeyName: 'mcp_tokens_identity_id_fkey'; + columns: ['identity_id']; + isOneToOne: false; + referencedRelation: 'agent_identities'; + referencedColumns: ['id']; + }, { foreignKeyName: 'mcp_tokens_user_id_fkey'; columns: ['user_id']; diff --git a/packages/api/src/mcp/auth/pcp-auth-provider.ts b/packages/api/src/mcp/auth/pcp-auth-provider.ts index 55ac3f37..dd3044f6 100644 --- a/packages/api/src/mcp/auth/pcp-auth-provider.ts +++ b/packages/api/src/mcp/auth/pcp-auth-provider.ts @@ -30,6 +30,7 @@ export interface PendingAuth { codeChallenge: string; redirectUri: string; state: string; + agentId?: string; expiresAt: number; } @@ -40,6 +41,7 @@ interface PendingAuthPayload { codeChallenge: string; redirectUri: string; state: string; + agentId?: string; } export interface AuthCode { @@ -48,6 +50,7 @@ export interface AuthCode { redirectUri: string; userId: string; userEmail: string; + agentId?: string; expiresAt: number; } @@ -105,6 +108,7 @@ export class PcpAuthProvider { codeChallenge: string; redirectUri: string; state: string; + agentId?: string; }): string { const payload: PendingAuthPayload = { type: 'pending_auth', @@ -112,6 +116,7 @@ export class PcpAuthProvider { codeChallenge: params.codeChallenge, redirectUri: params.redirectUri, state: params.state, + ...(params.agentId ? { agentId: params.agentId } : {}), }; return jwt.sign(payload, env.JWT_SECRET, { @@ -214,6 +219,7 @@ export class PcpAuthProvider { redirectUri: pending.redirectUri, userId: pcpUser.id, userEmail: pcpUser.email || '', + ...(pending.agentId ? { agentId: pending.agentId } : {}), expiresAt: Date.now() + AUTH_CODE_LIFETIME_MS, }); @@ -279,7 +285,25 @@ export class PcpAuthProvider { } } - // Create refresh token in database + // Resolve canonical identity UUID when agent_id is provided + let identityId: string | undefined; + if (codeData.agentId) { + const { data: identity } = await this.supabase + .from('agent_identities') + .select('id') + .eq('user_id', codeData.userId) + .eq('agent_id', codeData.agentId) + .maybeSingle(); + identityId = identity?.id; + if (!identityId) { + logger.warn('No agent_identities record found for token binding', { + userId: codeData.userId, + agentId: codeData.agentId, + }); + } + } + + // Create refresh token in database (with optional identity binding) let refreshToken: string; let expiresAt: Date; try { @@ -288,7 +312,9 @@ export class PcpAuthProvider { codeData.userId, clientId, ['mcp:tools'], - REFRESH_TOKEN_LIFETIME_DAYS + REFRESH_TOKEN_LIFETIME_DAYS, + codeData.agentId, + identityId ); refreshToken = result.refreshToken; expiresAt = result.expiresAt; @@ -299,13 +325,15 @@ export class PcpAuthProvider { // Consume the authorization code this.authCodes.delete(params.code); - // Sign our own JWT as the access token + // Sign our own JWT as the access token (with optional identity binding) const accessToken = signPcpAccessToken( { type: 'mcp_access', sub: codeData.userId, email: codeData.userEmail, scope: 'mcp:tools', + ...(codeData.agentId ? { agentId: codeData.agentId } : {}), + ...(identityId ? { identityId } : {}), }, ACCESS_TOKEN_LIFETIME_SECONDS ); @@ -314,6 +342,8 @@ export class PcpAuthProvider { userId: codeData.userId, email: codeData.userEmail, clientId, + agentId: codeData.agentId || 'none', + identityId: identityId || 'none', refreshTokenExpires: expiresAt.toISOString(), }); @@ -364,14 +394,21 @@ export class PcpAuthProvider { // Token verification (for /mcp endpoint auth) // -------------------------------------------------------------------------- - verifyAccessToken(authHeader: string | undefined): { userId: string; email: string } | null { + verifyAccessToken( + authHeader: string | undefined + ): { userId: string; email: string; agentId?: string; identityId?: string } | null { if (!authHeader?.startsWith('Bearer ')) return null; const token = authHeader.substring(7); const payload = verifyPcpAccessToken(token, 'mcp_access'); if (!payload) return null; - return { userId: payload.sub, email: payload.email }; + return { + userId: payload.sub, + email: payload.email, + ...(payload.agentId ? { agentId: payload.agentId } : {}), + ...(payload.identityId ? { identityId: payload.identityId } : {}), + }; } // -------------------------------------------------------------------------- diff --git a/packages/api/src/mcp/server.ts b/packages/api/src/mcp/server.ts index a544f85e..39c04e46 100644 --- a/packages/api/src/mcp/server.ts +++ b/packages/api/src/mcp/server.ts @@ -192,7 +192,14 @@ export class MCPServer { // Use request-scoped context (AsyncLocalStorage) instead of global state // to prevent identity leaking across concurrent stateless requests. - const ctx = userData ? { userId: userData.userId, email: userData.email } : {}; + const ctx = userData + ? { + userId: userData.userId, + email: userData.email, + agentId: userData.agentId, + identityId: userData.identityId, + } + : {}; await runWithRequestContext(ctx, async () => { let transport: StreamableHTTPServerTransport | undefined; @@ -324,9 +331,15 @@ export class MCPServer { // Authorization endpoint — redirects to web portal for login app.get('/authorize', (req, res) => { - const { client_id, redirect_uri, state, code_challenge, response_type } = req.query; + const { client_id, redirect_uri, state, code_challenge, response_type, agent_id } = req.query; - logger.info('MCP /authorize called', { client_id, redirect_uri, state, response_type }); + logger.info('MCP /authorize called', { + client_id, + redirect_uri, + state, + response_type, + agent_id, + }); if (response_type !== 'code') { res.status(400).json({ error: 'unsupported_response_type' }); @@ -338,6 +351,7 @@ export class MCPServer { codeChallenge: code_challenge as string, redirectUri: redirect_uri as string, state: state as string, + agentId: agent_id as string | undefined, }); const webPortalUrl = process.env.WEB_PORTAL_URL || 'http://localhost:3002'; diff --git a/packages/api/src/mcp/tools/activity-stream-handlers.ts b/packages/api/src/mcp/tools/activity-stream-handlers.ts index 18e63e31..e6c7dca7 100644 --- a/packages/api/src/mcp/tools/activity-stream-handlers.ts +++ b/packages/api/src/mcp/tools/activity-stream-handlers.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import type { DataComposer } from '../../data/composer'; import { logger } from '../../utils/logger'; +import { getEffectiveAgentId } from '../../auth/enforce-identity'; import { resolveUserOrThrow } from '../../services/user-resolver'; import type { ActivityType, @@ -165,7 +166,7 @@ export async function handleLogActivity(args: unknown, dataComposer: DataCompose const activity = await dataComposer.repositories.activityStream.logActivity({ userId: user.id, - agentId: params.agentId, + agentId: getEffectiveAgentId(params.agentId) ?? params.agentId, type: params.type as ActivityType, content: params.content, sessionId: params.sessionId, @@ -222,7 +223,7 @@ export async function handleLogMessage(args: unknown, dataComposer: DataComposer const activity = await dataComposer.repositories.activityStream.logMessage({ userId: user.id, - agentId: params.agentId, + agentId: getEffectiveAgentId(params.agentId) ?? params.agentId, direction: params.direction, content: params.content, sessionId: params.sessionId, diff --git a/packages/api/src/mcp/tools/artifact-handlers.ts b/packages/api/src/mcp/tools/artifact-handlers.ts index 465f2e66..05c91e42 100644 --- a/packages/api/src/mcp/tools/artifact-handlers.ts +++ b/packages/api/src/mcp/tools/artifact-handlers.ts @@ -11,6 +11,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { DataComposer } from '../../data/composer'; import { resolveUserOrThrow, userIdentifierBaseSchema } from '../../services/user-resolver'; import { logger } from '../../utils/logger'; +import { getEffectiveAgentId } from '../../auth/enforce-identity'; import type { Database, Json } from '../../data/supabase/types'; // ============== Schemas ============== @@ -207,13 +208,13 @@ export async function handleCreateArtifact(args: unknown, dataComposer: DataComp title, content, artifactType = 'document', - agentId, collaborators = [], visibility = 'private', tags = [], metadata = {}, workspaceId, } = parsed; + const agentId = getEffectiveAgentId(parsed.agentId); const authorIdentity = await resolveIdentityForAgent( supabase, resolved.user.id, @@ -485,12 +486,12 @@ export async function handleUpdateArtifact(args: unknown, dataComposer: DataComp title, content, baseVersion, - agentId, collaborators, tags, changeSummary, workspaceId, } = parsed; + const agentId = getEffectiveAgentId(parsed.agentId); const editorIdentity = await resolveIdentityForAgent( supabase, resolved.user.id, diff --git a/packages/api/src/mcp/tools/identity-handlers.ts b/packages/api/src/mcp/tools/identity-handlers.ts index 6a9570e0..7df3cde9 100644 --- a/packages/api/src/mcp/tools/identity-handlers.ts +++ b/packages/api/src/mcp/tools/identity-handlers.ts @@ -11,6 +11,7 @@ import { homedir } from 'os'; import type { DataComposer } from '../../data/composer'; import type { Json, TablesInsert } from '../../data/supabase/types'; import { logger } from '../../utils/logger'; +import { getEffectiveAgentId } from '../../auth/enforce-identity'; import { userIdentifierBaseSchema, resolveUserOrThrow } from '../../services/user-resolver'; // ===================================================== @@ -194,7 +195,6 @@ export async function handleSaveIdentity(args: unknown, dataComposer: DataCompos const supabase = dataComposer.getClient(); const { - agentId, name, role, description, @@ -207,6 +207,8 @@ export async function handleSaveIdentity(args: unknown, dataComposer: DataCompos syncToFile, workspaceId, } = params; + // Enforce identity: pinned agents can only modify their own identity + const agentId = getEffectiveAgentId(params.agentId) ?? params.agentId; // Fetch existing record so omitted optional fields are preserved const { data: existing } = await withWorkspaceFilter( diff --git a/packages/api/src/mcp/tools/inbox-handlers.ts b/packages/api/src/mcp/tools/inbox-handlers.ts index 53206c89..a7a4d82a 100644 --- a/packages/api/src/mcp/tools/inbox-handlers.ts +++ b/packages/api/src/mcp/tools/inbox-handlers.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import type { DataComposer } from '../../data/composer'; import { resolveUserOrThrow, userIdentifierBaseSchema } from '../../services/user-resolver'; import { resolveIdentityId } from '../../auth/resolve-identity'; +import { getEffectiveAgentId } from '../../auth/enforce-identity'; import { logger } from '../../utils/logger'; import type { Json } from '../../data/supabase/types'; import { getAgentGateway, type AgentTriggerPayload } from '../../channels/agent-gateway.js'; @@ -94,7 +95,6 @@ export async function handleSendToInbox(args: unknown, dataComposer: DataCompose const { recipientAgentId, - senderAgentId, subject, content, messageType = 'message', @@ -107,6 +107,8 @@ export async function handleSendToInbox(args: unknown, dataComposer: DataCompose triggerSummary, threadKey, } = parsed; + // Enforce identity on sender (who is performing the action), not recipient (target) + const senderAgentId = getEffectiveAgentId(parsed.senderAgentId); // Default trigger behavior based on message type: // task_request and session_resume trigger immediately (time-sensitive handoffs) @@ -245,7 +247,9 @@ export async function handleGetInbox(args: unknown, dataComposer: DataComposer) const parsed = getInboxSchema.parse(args); const resolved = await resolveUserOrThrow(parsed, dataComposer); - const { agentId, status = 'unread', priority, messageType, limit = 20 } = parsed; + const { status = 'unread', priority, messageType, limit = 20 } = parsed; + // Enforce identity: pinned agents can only read their own inbox + const agentId = getEffectiveAgentId(parsed.agentId) ?? parsed.agentId; let query = supabase .from('agent_inbox') diff --git a/packages/api/src/mcp/tools/memory-handlers.test.ts b/packages/api/src/mcp/tools/memory-handlers.test.ts index 8709b729..d5895f46 100644 --- a/packages/api/src/mcp/tools/memory-handlers.test.ts +++ b/packages/api/src/mcp/tools/memory-handlers.test.ts @@ -43,6 +43,9 @@ vi.mock('../../utils/logger', () => ({ // Mock request-context vi.mock('../../utils/request-context', () => ({ setSessionContext: vi.fn(), + pinSessionAgent: vi.fn(), + getPinnedAgentId: vi.fn().mockReturnValue(null), + getRequestContext: vi.fn().mockReturnValue(undefined), })); // Mock cloud skills @@ -1065,7 +1068,13 @@ describe('startSessionSchema - threadKey', () => { }); it('should accept various threadKey formats', () => { - const formats = ['pr:32', 'spec:cli-hooks', 'issue:45', 'branch:wren/feat/x', 'thread:perf-audit']; + const formats = [ + 'pr:32', + 'spec:cli-hooks', + 'issue:45', + 'branch:wren/feat/x', + 'thread:perf-audit', + ]; for (const key of formats) { const result = startSessionSchema.safeParse({ email: 'test@test.com', diff --git a/packages/api/src/mcp/tools/memory-handlers.ts b/packages/api/src/mcp/tools/memory-handlers.ts index 6f16a5b3..a795de17 100644 --- a/packages/api/src/mcp/tools/memory-handlers.ts +++ b/packages/api/src/mcp/tools/memory-handlers.ts @@ -11,7 +11,8 @@ import * as os from 'os'; import type { DataComposer } from '../../data/composer'; import { logger } from '../../utils/logger'; import { userIdentifierBaseSchema, resolveUserOrThrow } from '../../services/user-resolver'; -import { setSessionContext } from '../../utils/request-context'; +import { setSessionContext, pinSessionAgent, getRequestContext } from '../../utils/request-context'; +import { getEffectiveAgentId } from '../../auth/enforce-identity'; import type { MemorySource, Salience } from '../../data/models/memory'; import { getCloudSkillsService } from '../../skills/cloud-service'; @@ -41,7 +42,13 @@ function resolveStudioId(params: { studioId?: string; workspaceId?: string }): s /** Coerce a comma-separated string into a string array so callers can pass either format. */ const topicsSchema = z .preprocess( - (val) => (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : val), + (val) => + typeof val === 'string' + ? val + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : val, z.array(z.string()) ) .optional(); @@ -355,6 +362,7 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) const params = rememberSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const studioId = resolveStudioId(params); + const agentId = getEffectiveAgentId(params.agentId); // If there's an active session, attach its ID to the memory metadata for traceability. // Never require a session — memories are too important to lose. @@ -362,7 +370,7 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) try { const activeSession = await dataComposer.repositories.memory.getActiveSession( user.id, - params.agentId, + agentId, studioId ); sessionId = activeSession?.id; @@ -384,13 +392,13 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer) topics: params.topics, metadata, expiresAt: params.expiresAt ? new Date(params.expiresAt) : undefined, - agentId: params.agentId, + agentId, }); logger.info(`Memory created for user ${user.id}`, { memoryId: memory.id, source: memory.source, - agentId: params.agentId, + agentId: agentId || 'none', sessionId: sessionId || 'none', }); @@ -549,16 +557,17 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos const params = startSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const studioId = resolveStudioId(params); + const agentId = getEffectiveAgentId(params.agentId); // Session matching priority: // 1. threadKey match — find active session with same agent+threadKey // 2. studioId match — find active session scoped by agent+studio (existing behavior) let existingSession = null; - if (params.threadKey && params.agentId) { + if (params.threadKey && agentId) { existingSession = await dataComposer.repositories.memory.getActiveSessionByThreadKey( user.id, - params.agentId, + agentId, params.threadKey, studioId ); @@ -567,7 +576,7 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos if (!existingSession) { existingSession = await dataComposer.repositories.memory.getActiveSession( user.id, - params.agentId, + agentId, studioId ); } @@ -602,7 +611,7 @@ export async function handleStartSession(args: unknown, dataComposer: DataCompos const session = await dataComposer.repositories.memory.startSession({ userId: user.id, - agentId: params.agentId, + agentId, studioId, workspaceId: params.workspaceId, threadKey: params.threadKey, @@ -647,13 +656,14 @@ export async function handleLogSession(args: unknown, dataComposer: DataComposer const params = logSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const studioId = resolveStudioId(params); + const agentId = getEffectiveAgentId(params.agentId); // Get session ID (use provided or find active, scoped by agent+studio) let sessionId = params.sessionId; if (!sessionId) { const activeSession = await dataComposer.repositories.memory.getActiveSession( user.id, - params.agentId, + agentId, studioId ); if (!activeSession) { @@ -711,13 +721,14 @@ export async function handleEndSession(args: unknown, dataComposer: DataComposer const params = endSessionSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); const studioId = resolveStudioId(params); + const agentId = getEffectiveAgentId(params.agentId); // Get session ID (use provided or find active, scoped by agent+studio) let sessionId = params.sessionId; if (!sessionId) { const activeSession = await dataComposer.repositories.memory.getActiveSession( user.id, - params.agentId, + agentId, studioId ); if (!activeSession) { @@ -1259,6 +1270,19 @@ export async function handleBootstrap(args: unknown, dataComposer: DataComposer) const params = bootstrapSchema.parse(args); const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer); + // Pin the agent identity for this session (immutable once set). + // If request context already has an agentId from a token, validate it matches. + if (params.agentId) { + const reqCtx = getRequestContext(); + if (reqCtx?.agentId && reqCtx.agentId !== params.agentId) { + throw new Error( + `Token is bound to agent "${reqCtx.agentId}" but bootstrap was called with "${params.agentId}". ` + + `Use a token issued for this agent, or remove the agent_id from the token.` + ); + } + pinSessionAgent(params.agentId); + } + // Set session context so subsequent MCP tool calls can use this user setSessionContext({ userId: user.id, diff --git a/packages/api/src/utils/request-context.ts b/packages/api/src/utils/request-context.ts index 3faf457a..7400351f 100644 --- a/packages/api/src/utils/request-context.ts +++ b/packages/api/src/utils/request-context.ts @@ -29,8 +29,10 @@ export interface RequestContextData { platform?: 'telegram' | 'whatsapp' | 'discord'; /** Platform-specific user ID */ platformId?: string; - /** Agent ID if known */ + /** Agent ID if known (text label) */ agentId?: string; + /** Canonical agent_identities UUID (strongest identity binding) */ + identityId?: string; /** Session ID if in a session */ sessionId?: string; /** Active product workspace container ID */ @@ -47,6 +49,10 @@ const asyncLocalStorage = new AsyncLocalStorage(); // Used primarily for Claude Code where context is set at bootstrap let sessionContext: Omit | null = null; +// Session-scoped identity pin (immutable once set by bootstrap or token) +// Prevents mid-session identity changes (e.g. via prompt injection) +let pinnedSessionAgentId: string | null = null; + /** * Run a function with request context set. * All code within the callback can access the context via getRequestContext(). @@ -138,6 +144,55 @@ export function hasUserContext(): boolean { return !!(user.userId || user.email || (user.platform && user.platformId)); } +// ============================================================================ +// Identity Pinning +// ============================================================================ + +/** + * Pin the session to a specific agent identity. + * Once pinned, the identity is immutable for the session lifetime. + * Called by bootstrap() and when an agent-bound token is first used. + * Throws if already pinned to a different identity. + */ +export function pinSessionAgent(agentId: string): void { + if (pinnedSessionAgentId !== null && pinnedSessionAgentId !== agentId) { + throw new Error( + `Identity already pinned to "${pinnedSessionAgentId}". Cannot change to "${agentId}".` + ); + } + pinnedSessionAgentId = agentId; +} + +/** + * Get the pinned agent identity. + * + * In HTTP mode (request context exists): returns agentId from the token only. + * The global session pin is NEVER consulted — it's process-global and would + * leak identity across concurrent requests from different users/agents. + * + * In stdio mode (no request context): returns the session pin set by bootstrap(). + * Safe because stdio is single-session-per-process. + * + * Returns null if no identity is pinned (human user or pre-bootstrap). + */ +export function getPinnedAgentId(): string | null { + const reqCtx = getRequestContext(); + if (reqCtx) { + // HTTP mode: only trust the token-bound agentId, never the global pin + return reqCtx.agentId ?? null; + } + // stdio mode: use the session pin from bootstrap() + return pinnedSessionAgentId; +} + +/** + * Clear the pinned agent identity. + * Used when cleaning up session state. + */ +export function clearPinnedAgent(): void { + pinnedSessionAgentId = null; +} + /** * Merge explicit args with context. * Explicit values take precedence over context. diff --git a/supabase/migrations/20260216084536_sb_auth_token_binding.sql b/supabase/migrations/20260216084536_sb_auth_token_binding.sql new file mode 100644 index 00000000..d180c9ce --- /dev/null +++ b/supabase/migrations/20260216084536_sb_auth_token_binding.sql @@ -0,0 +1,7 @@ +-- SB Auth: Token-bound identity +-- Binds an OAuth token to a specific agent identity (text label + canonical UUID) + +ALTER TABLE mcp_tokens ADD COLUMN IF NOT EXISTS agent_id text; +ALTER TABLE mcp_tokens ADD COLUMN IF NOT EXISTS identity_id uuid REFERENCES agent_identities(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_agent_id ON mcp_tokens(agent_id); +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_identity_id ON mcp_tokens(identity_id);