diff --git a/docs/capy.md b/docs/capy.md index 6194c40..1cd81f8 100644 --- a/docs/capy.md +++ b/docs/capy.md @@ -87,8 +87,30 @@ Before mounting new work, add routes, callback verification, subscriptions and durable delivery. No production caller writes binding rows yet. -OAuth `assertActor`/`pin` is unchanged. A future OAuth-bound Capy journey is a -separate admission/parity change, not an alias-table bypass in this registry. +## Explicit OAuth reply permits (internal, not mounted) + +Migration `0046_capy_grant_permits.sql` keeps reply authority separate from the +native binding. Optional `bind_capy_thread(thread_id)` requires an existing bound +thread owned by this grant's person, current owner/admin membership and ownership +of the connected key. It creates one live permit for the exact grant and binding, +snapshotting generation, immutable remote ID and name. Repeated binds return the +existing binding with `already_bound: true`; they never create or transfer a +binding or change the approved OAuth persona. + +Only `send_relay_message as=` may resolve this permit. Generic +`assertActor`/`pin`, retirement, inbox, conversation, channel, launch and Desktop +identity rules remain unchanged. Every send checks the live bearer and permit, +canonical key/role/route and remote custody before and after verification and +again after parent/address reads. Revoking the grant revokes its permits in the +same transaction. Binding retirement or generation/identity changes fail closed. +Revocation cannot retract a write already accepted by Relaycast. + +`unbind_capy_thread(thread_id)` revokes only this grant's reply permit; other +grants and native inbound delivery retain their independent binding authority. +The optional MCP module is not supplied by the application server and runtime +capability remains false. Before mounting, measure the extra remote checks and +complete Control-owned callback/subscription/durable delivery. Those messaging +callbacks do not belong to the separate provider-file ingestion lane. ## Required next slices diff --git a/migrations/0046_capy_grant_permits.sql b/migrations/0046_capy_grant_permits.sql new file mode 100644 index 0000000..6f32a33 --- /dev/null +++ b/migrations/0046_capy_grant_permits.sql @@ -0,0 +1,23 @@ +-- A reply permit belongs to one OAuth grant, never to its base persona. +-- Native binding custody and inbound delivery are independent of this permit. +ALTER TABLE capy_bindings ADD CONSTRAINT capy_bindings_permit_owner + UNIQUE (id,organization_id,workspace_id,user_id); +CREATE TABLE capy_grant_permits ( + id uuid PRIMARY KEY, + organization_id uuid NOT NULL, + workspace_id uuid NOT NULL, + user_id uuid NOT NULL, + grant_id uuid NOT NULL, + binding_id uuid NOT NULL, + binding_generation integer NOT NULL CHECK (binding_generation > 0), + agent_remote_id text NOT NULL CHECK (length(agent_remote_id) > 0), + agent_name text NOT NULL CHECK (length(agent_name) > 0), + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, + revoke_reason text, + FOREIGN KEY (grant_id,organization_id,workspace_id,user_id) + REFERENCES mcp_oauth_grants(id,organization_id,workspace_id,user_id) ON DELETE CASCADE, + FOREIGN KEY (binding_id,organization_id,workspace_id,user_id) + REFERENCES capy_bindings(id,organization_id,workspace_id,user_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX capy_grant_permits_live ON capy_grant_permits(grant_id,binding_id) WHERE revoked_at IS NULL; diff --git a/src/capy/bindings.ts b/src/capy/bindings.ts index 3578112..5c7f957 100644 --- a/src/capy/bindings.ts +++ b/src/capy/bindings.ts @@ -3,12 +3,13 @@ import { decryptSecret, encryptSecret, hashSecret, type EncryptionKeyring } from import type { Database, SqlExecutor } from "../database.js"; import { RelaycastCallError, type RelaycastClient, type RemoteAgent } from "../messaging/relaycast.js"; import { HOSTED_SOURCE, ownerHash } from "../mcp/relay-participants.js"; +import type { McpOAuthConnection } from "../mcp/oauth/admission.js"; import { lockOrganization, lockUser } from "../tenancy.js"; import { CapyRuntimeError, type CapyRuntime } from "./runtime.js"; /** Internal service, called only after person admission. Never pass model/user - * supplied acting IDs here. Native integration must use !managed && !oauth, - * exactly like Factory; this service does not extend OAuth assertActor/pin. + * supplied acting IDs here. Native integration uses !managed && !oauth. + * OAuth replies require a separate exact-grant permit; assertActor/pin stay unchanged. */ export type CapyActor = { userId: string; organizationId: string; workspaceId: string }; export class CapyBindingError extends Error { @@ -29,6 +30,7 @@ export const capyAgentName = (threadId: string) => `capy-${hashSecret(threadId). const THREAD = /^jam_[A-Za-z0-9]{6,64}$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const changed = () => new CapyBindingError(409, "capy_binding_changed"); +type Permit = { id: string; binding_id: string; binding_generation: number; agent_remote_id: string; agent_name: string }; export class CapyBindings { private readonly now: () => Date; @@ -238,6 +240,90 @@ export class CapyBindings { } } + /** Explicit self-bind adds only this grant's send permit to an existing + * native binding. It never creates/reassigns a binding or pins a persona. + */ + async bindGrant(connection: McpOAuthConnection, threadId: string): Promise { + if (!THREAD.test(threadId)) throw new CapyBindingError(400,"capy_invalid_thread"); + const actor = connection.access.principal; + return this.locked(actor, async tx => { + await connection.lock(tx); + const row = (await tx.query(`SELECT * FROM capy_bindings WHERE thread_id=$1 + AND workspace_id=$2 AND organization_id=$3 AND user_id=$4 AND state='bound' FOR UPDATE`, + [threadId,actor.workspaceId,actor.organizationId,actor.userId])).rows[0]; + if (!row) throw new CapyBindingError(404,"capy_binding_not_found"); + const live = await this.current(tx,actor,row); + if (!live.token_ciphertext || !live.agent_remote_id || capyAgentName(threadId) !== live.agent_name) throw changed(); + // A stale generation/name is not repaired by use. Only an explicit bind + // under this grant may replace its old permit with a fresh snapshot. + await tx.query(`UPDATE capy_grant_permits SET revoked_at=$3,revoke_reason='binding_changed' + WHERE grant_id=$1 AND binding_id=$2 AND revoked_at IS NULL + AND (binding_generation<>$4 OR agent_remote_id<>$5 OR agent_name<>$6)`, + [connection.access.grantId,live.id,this.now(),live.generation,live.agent_remote_id,live.agent_name]); + await tx.query(`INSERT INTO capy_grant_permits + (id,organization_id,workspace_id,user_id,grant_id,binding_id,binding_generation,agent_remote_id,agent_name,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (grant_id,binding_id) WHERE revoked_at IS NULL DO NOTHING`, + [randomUUID(),actor.organizationId,actor.workspaceId,actor.userId,connection.access.grantId, + live.id,live.generation,live.agent_remote_id,live.agent_name,this.now()]); + return { ...view(live),already_bound:true }; + }); + } + + /** Revoke only this grant's reply permit. Native binding/injection remains. */ + async unbindGrant(connection: McpOAuthConnection, threadId: string): Promise<{ revoked: boolean }> { + if (!THREAD.test(threadId)) throw new CapyBindingError(400,"capy_invalid_thread"); + const actor = connection.access.principal; + return this.locked(actor, async tx => { + await connection.lock(tx); + const result = await tx.query(`UPDATE capy_grant_permits p SET revoked_at=$5,revoke_reason='explicit_unbind' + FROM capy_bindings b WHERE p.binding_id=b.id AND p.grant_id=$1 AND p.user_id=$2 + AND p.organization_id=$3 AND p.workspace_id=$4 AND b.thread_id=$6 AND p.revoked_at IS NULL RETURNING p.id`, + [connection.access.grantId,actor.userId,actor.organizationId,actor.workspaceId,this.now(),threadId]); + return { revoked:result.rows.length>0 }; + }); + } + + private async grantPermit(tx: SqlExecutor, connection: McpOAuthConnection, name: string) { + await connection.lock(tx); + const actor = connection.access.principal; + const permit = (await tx.query(`SELECT p.* FROM capy_grant_permits p + WHERE p.grant_id=$1 AND p.organization_id=$2 AND p.workspace_id=$3 AND p.user_id=$4 + AND p.agent_name=$5 AND p.revoked_at IS NULL FOR UPDATE`, + [connection.access.grantId,actor.organizationId,actor.workspaceId,actor.userId,name])).rows[0]; + if (!permit) return null; + const row = (await tx.query("SELECT * FROM capy_bindings WHERE id=$1 FOR UPDATE",[permit.binding_id])).rows[0]; + if (!row || row.state!=="bound" || row.generation!==permit.binding_generation || + row.agent_remote_id!==permit.agent_remote_id || row.agent_name!==permit.agent_name || + !row.token_ciphertext || capyAgentName(row.thread_id)!==name) return null; + return { permit,row:await this.current(tx,actor,row) }; + } + + /** Send-only OAuth resolver. Never becomes a generic participant/identity. */ + async agentForGrant(connection: McpOAuthConnection, name: string): Promise<{ name: string; token: string } | null> { + if (!name.startsWith("capy-")) return null; + const actor = connection.access.principal; + try { + const selected = await this.locked(actor,tx=>this.grantPermit(tx,connection,name)); + if (!selected) return null; + // Remote verification takes place outside SQL locks. Recheck the same + // permit and exact binding/token afterwards, including grant revocation. + const found = await this.agentFor(actor.workspaceId,actor.userId,name); + if (!found) return null; + return this.locked(actor,async tx=>{ + const current = await this.grantPermit(tx,connection,name); + if (!current || current.permit.id!==selected.permit.id || current.row.id!==selected.row.id || + current.row.generation!==selected.row.generation || current.row.agent_remote_id!==selected.row.agent_remote_id || + current.row.token_ciphertext!==selected.row.token_ciphertext || + decryptSecret(current.row.token_ciphertext!,this.deps.keyring)!==found.token) return null; + return found; + }); + } catch (error) { + if (error instanceof CapyBindingError && error.status!==503) return null; + throw error; + } + } + async retire(actor: CapyActor, id: string): Promise { if (!UUID.test(id)) throw new CapyBindingError(404,"capy_binding_not_found"); await this.locked(actor, async tx => { diff --git a/src/capy/mcp-permits.ts b/src/capy/mcp-permits.ts new file mode 100644 index 0000000..c70b8b2 --- /dev/null +++ b/src/capy/mcp-permits.ts @@ -0,0 +1,34 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { McpContext } from "../mcp/context.js"; +import { McpOAuthAccessRefused } from "../mcp/oauth/admission.js"; +import { CapyBindingError } from "./bindings.js"; + +/** Optional OAuth send permits only. No binding creation, transfer or persona pin. */ +export function registerCapyPermitTools(server: McpServer, context: McpContext): void { + const run = async (work: () => Promise) => { + try { + const oauth = context.oauth; + if (!oauth || !context.capyPermits || oauth.access.principal.userId!==context.principal.userId || + oauth.access.principal.organizationId!==context.workspace.organizationId || + oauth.access.principal.workspaceId!==context.workspace.id || + context.principal.organizationId!==context.workspace.organizationId || + context.principal.workspaceId!==context.workspace.id) throw new McpOAuthAccessRefused(); + const data = await work(); + return { content:[{ type:"text" as const,text:JSON.stringify(data) }] }; + } catch (error) { + return { isError:true,content:[{type:"text" as const,text:error instanceof CapyBindingError ? error.code + : error instanceof McpOAuthAccessRefused ? error.message : "Capy reply permit is temporarily unavailable."}] }; + } + }; + const inputSchema = { thread_id:z.string().regex(/^jam_[A-Za-z0-9]{6,64}$/) }; + const annotations = { readOnlyHint:false,destructiveHint:false,idempotentHint:true,openWorldHint:false }; + server.registerTool("bind_capy_thread",{ + description:"Allow this OAuth connection to send replies as an existing Capy thread binding owned by your connected key. Returns the existing binding; creates only a grant-specific send permit. Does not create or transfer a binding, change your approved persona, or grant inbox/channel/history access.", + inputSchema,annotations, + },({thread_id})=>run(()=>context.capyPermits!.bindGrant(context.oauth!,thread_id))); + server.registerTool("unbind_capy_thread",{ + description:"Revoke only this OAuth connection's Capy reply permit for the selected thread. Native binding custody and inbound delivery remain active; other connections' permits are unchanged.", + inputSchema,annotations:{...annotations,destructiveHint:true}, + },({thread_id})=>run(()=>context.capyPermits!.unbindGrant(context.oauth!,thread_id))); +} diff --git a/src/control-tables.ts b/src/control-tables.ts index 696c4c8..e319bfa 100644 --- a/src/control-tables.ts +++ b/src/control-tables.ts @@ -10,6 +10,7 @@ export const CONTROL_TABLES: readonly string[] = [ "amp_history_syncs", "capture_policies", "capy_bindings", + "capy_grant_permits", "cli_credential_refs", "coding_agent_credential_blobs", "coding_agent_credentials", diff --git a/src/mcp/capabilities.ts b/src/mcp/capabilities.ts index c0e72bf..4cbff83 100644 --- a/src/mcp/capabilities.ts +++ b/src/mcp/capabilities.ts @@ -24,6 +24,7 @@ export function registerCapabilityDiscovery(server: McpServer, context: McpConte amp: Boolean(context.amp), factory: Boolean(context.factory), launch: Boolean(context.launch), + capyPermits: Boolean(context.oauth && context.capyPermits), providers: await context.providers(), }); return { diff --git a/src/mcp/catalog.ts b/src/mcp/catalog.ts index b359608..d8ae0fe 100644 --- a/src/mcp/catalog.ts +++ b/src/mcp/catalog.ts @@ -95,7 +95,7 @@ export const TOOL_GROUPS: Readonly> = { export type ToolPlanEntry = { group: ToolGroupId; tools: readonly string[] }; /** The self-host shared-sessions plan, in registration order. */ -export function planToolGroups(options: { amp?: boolean; factory?: boolean; launch?: boolean } = {}): ToolPlanEntry[] { +export function planToolGroups(options: { amp?: boolean; factory?: boolean; launch?: boolean; capyPermits?: boolean } = {}): ToolPlanEntry[] { const groups: ToolGroupId[] = ["shared_session_reads", "team_activity", "relay_messaging"]; // Launch tools exist only when SELFHOST_FEATURES selects launch; role and // connection kind are checked on every call. @@ -104,6 +104,7 @@ export function planToolGroups(options: { amp?: boolean; factory?: boolean; laun if (options.amp) groups.push("amp"); // Factory tools exist only when SELFHOST_FACTORY_ENABLED is on. if (options.factory) groups.push("factory"); + if (options.capyPermits) groups.push("capy_binding"); groups.push("capability_discovery"); return groups.map(group => ({ group, tools: TOOL_GROUPS[group].tools })); } @@ -167,11 +168,13 @@ export function describeCapabilities(input: { amp?: boolean; factory?: boolean; launch?: boolean; + capyPermits?: boolean; providers: ProviderStatus[]; }): CapabilityReport { const launchRole = input.role === "owner" || input.role === "admin"; const canLaunch = Boolean(input.launch) && input.authMode !== "oauth" && launchRole; - const planned = new Map(planToolGroups({ amp: input.amp, factory: input.factory, launch: input.launch && input.authMode !== "oauth" }) + const planned = new Map(planToolGroups({ amp: input.amp, factory: input.factory, launch: input.launch && input.authMode !== "oauth", + capyPermits: input.capyPermits && input.authMode === "oauth" }) .map(item => [item.group, item])); const groups = (Object.keys(TOOL_GROUPS) as ToolGroupId[]).map((id): CapabilityGroup => { const definition = TOOL_GROUPS[id]; diff --git a/src/mcp/context.ts b/src/mcp/context.ts index d81988c..93b91c1 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -65,6 +65,8 @@ export type McpContext = { factory?: FactoryMcpDeps; /** Internal native send resolver; absent until a complete Capy module is mounted. */ capy?: Pick; + /** Explicit exact-grant reply permits; never the native resolver or a persona pin. */ + capyPermits?: Pick; /** Where shared-session read calls are audited (src/mcp/read-audit.ts); absent writes nothing. */ readAudit?: { database: SqlExecutor; diff --git a/src/mcp/oauth/revocations.ts b/src/mcp/oauth/revocations.ts index 3ac994e..6641fe4 100644 --- a/src/mcp/oauth/revocations.ts +++ b/src/mcp/oauth/revocations.ts @@ -10,6 +10,8 @@ export async function revokeMcpOAuthGrantRows(tx:SqlExecutor,id:string,reason:st const grant=(await tx.query("SELECT * FROM mcp_oauth_grants WHERE id=$1 FOR UPDATE",[id])).rows[0]; if(!grant) return false; await tx.query(`UPDATE mcp_oauth_grants SET revoked_at=$2,revoked_reason=$3 WHERE id=$1 AND revoked_at IS NULL`,[id,now,reason]); + await tx.query(`UPDATE capy_grant_permits SET revoked_at=$2,revoke_reason='grant_revoked' + WHERE grant_id=$1 AND revoked_at IS NULL`,[id,now]); await revokeMcpOAuthSessionFamily(tx,{ grantId:id,tokenFamilyId:grant.token_family_id,userId:grant.user_id, organizationId:grant.organization_id,workspaceId:grant.workspace_id, diff --git a/src/mcp/relay-participants.ts b/src/mcp/relay-participants.ts index 896b6c5..29740fd 100644 --- a/src/mcp/relay-participants.ts +++ b/src/mcp/relay-participants.ts @@ -222,7 +222,7 @@ export function relayFailure(error: unknown, operation?: RelayToolOperation) { // The per-request relay service. /** `desktop` marks a synced Agent Relay Desktop identity; its deliveries are injected, not polled. */ -type Identity = { name: string; token: string; desktop?: boolean; factory?: boolean; capy?: boolean }; +type Identity = { name: string; token: string; desktop?: boolean; factory?: boolean; capy?: boolean; capyGrant?: boolean }; type DesktopActor = LinkedDesktopIdentity & { desktop: true }; /** * `remotePresent` and the ownership checks come from the token or, when @@ -651,8 +651,15 @@ function createRelayService(context: McpContext) { const capyFallback = (as: string | undefined): as is string => Boolean( as?.startsWith("capy-") && !managed && !oauth && context.capy); - async function capyActor(as: string): Promise { - const found = await context.capy!.agentFor(workspaceId, userId, as); + async function capyActor(as: string, grant = false): Promise { + if (grant && (!oauth || oauth.access.principal.userId!==userId || + oauth.access.principal.organizationId!==organizationId || oauth.access.principal.workspaceId!==workspaceId || + context.principal.organizationId!==organizationId || context.principal.workspaceId!==workspaceId)) { + throw new RelayToolError(NO_PARTICIPANT_MESSAGE); + } + const found = grant ? await context.capyPermits!.agentForGrant(oauth!,as) + : await context.capy!.agentFor(workspaceId, userId, as); + if (!found && grant) return null; // No permit: retain the original persona/assertActor path. if (!found || found.name !== as || !found.token) throw new RelayToolError(NO_PARTICIPANT_MESSAGE); // agentFor resolves current custody. The recipient was resolved using this // request's admitted route: never mix it with a newly rebound workspace. @@ -661,7 +668,7 @@ function createRelayService(context: McpContext) { [workspaceId,organizationId])).rows[0]; if (!route || route.relaycast_workspace_id !== relayWorkspaceId || decryptSecret(route.relaycast_api_key_ciphertext,keyring) !== key) throw new RelayToolError(NO_PARTICIPANT_MESSAGE); - return { name: found.name, token: found.token, capy: true }; + return { name: found.name, token: found.token, capy: true, ...(grant ? { capyGrant:true } : {}) }; } /** Native provider identities never become participant rows. Only send opts into Capy. */ @@ -673,12 +680,16 @@ function createRelayService(context: McpContext) { await managedActorOnly(as); return fn(await desktop()); } + if (options.capy && as?.startsWith("capy-") && oauth && context.capyPermits && as!==oauth.access.agentName) { + const permitted = await capyActor(as,true); + if (permitted) return fn(permitted); + } if (!as) { const linkedIdentity = await linkedDesktop(); if (linkedIdentity) return fn(linkedIdentity); } let hosted = await participant(as, { allowMissing: Boolean(oauth) || viaFactory || viaCapy }); - if (!hosted && viaCapy) return fn(await capyActor(as)); + if (!hosted && viaCapy) return fn((await capyActor(as))!); if (!hosted && viaFactory) return fn(await factoryActor(as)); if (!hosted) { await join({}); @@ -912,8 +923,8 @@ function createRelayService(context: McpContext) { // Parent/address reads may cross a disconnect or key rotation. Resolve // the same binding again immediately before the external write. if (identity.capy) { - const current = await capyActor(identity.name); - if (current.token !== identity.token) throw new RelayToolError(NO_PARTICIPANT_MESSAGE); + const current = await capyActor(identity.name,identity.capyGrant); + if (!current || current.token !== identity.token) throw new RelayToolError(NO_PARTICIPANT_MESSAGE); } let sent; if (input.reply_to) { diff --git a/src/mcp/routes.ts b/src/mcp/routes.ts index a63ff13..d7f4acc 100644 --- a/src/mcp/routes.ts +++ b/src/mcp/routes.ts @@ -119,6 +119,8 @@ export function createMcpHandler(input: { factory?: FactoryMcpDeps; /** Internal native send resolver, never supplied to OAuth or managed calls. */ capy?: McpContext["capy"]; + /** Optional exact-grant Capy send permits, supplied only to OAuth contexts. */ + capyPermits?: McpContext["capyPermits"]; /** Provider runtime probes (src/providers/registry.ts); empty when unset. */ providerProbes?: ProviderProbes; /** SELFHOST_FEATURES launch: register the agent launch tools. */ @@ -331,6 +333,7 @@ export function createMcpHandler(input: { ...(!oauth && input.amp ? { amp: input.amp } : {}), ...(!oauth && input.factory ? { factory: input.factory } : {}), ...(!oauth && !desktopCall.required && desktopCall.provider === undefined && input.capy ? { capy: input.capy } : {}), + ...(oauth && input.capyPermits ? { capyPermits: input.capyPermits } : {}), ...(!oauth && input.launch ? { launch: true } : {}), readAudit: { database: store.database, handled: new Set(), ...(input.now ? { now: input.now } : {}) }, }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 308e5a6..0e3aea0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAmpTools } from "../amp/mcp-tools.js"; import { registerFactoryTools } from "../factory/mcp-tools.js"; +import { registerCapyPermitTools } from "../capy/mcp-permits.js"; import { SELFHOST_API_VERSION } from "../config.js"; import { registerCapabilityDiscovery } from "./capabilities.js"; import { ensureToolTitles, MCP_FACADE, planToolGroups, registerToolPlan } from "./catalog.js"; @@ -33,7 +34,8 @@ const OAUTH_MCP_INSTRUCTIONS = export function buildMcpServer(context: McpContext): McpServer { const server = new McpServer( { name: "agent-relay-selfhost", title: MCP_FACADE.name, version: SELFHOST_API_VERSION }, - { instructions: context.oauth ? OAUTH_MCP_INSTRUCTIONS : SELFHOST_MCP_INSTRUCTIONS, + { instructions: context.oauth ? OAUTH_MCP_INSTRUCTIONS + (context.capyPermits + ? " Explicit bind_capy_thread may additionally permit send_relay_message as that existing Capy binding. This never grants inbox, channel, history or launch access as Capy." : "") : SELFHOST_MCP_INSTRUCTIONS, capabilities: context.oauth ? {} : { experimental: { [NATIVE_SESSION_CAPABILITY]: { version: 1 }, // All are implemented here: Claude and Copilot proofs are verified @@ -51,7 +53,8 @@ export function buildMcpServer(context: McpContext): McpServer { const factory = context.factory; // OAuth connections never get launch tools (describe_capabilities says why). const launch = Boolean(context.launch) && !context.oauth; - registerToolPlan(planToolGroups({ amp: Boolean(amp), factory: Boolean(factory), launch }), { + const capyPermits = Boolean(context.oauth && context.capyPermits); + registerToolPlan(planToolGroups({ amp: Boolean(amp), factory: Boolean(factory), launch,capyPermits }), { shared_session_reads: () => registerSharedSessionReads(server, context), team_activity: () => registerTeamActivity(server, context), relay_messaging: () => registerRelayMessaging(server, context), @@ -59,6 +62,7 @@ export function buildMcpServer(context: McpContext): McpServer { ...(launch ? { hosted_launch: () => registerRelaySpawnTools(server, context) } : {}), ...(amp ? { amp: () => registerAmpTools(server, context, amp) } : {}), ...(factory ? { factory: () => registerFactoryTools(server, context, factory) } : {}), + ...(capyPermits ? { capy_binding: () => registerCapyPermitTools(server,context) } : {}), }); return server; } diff --git a/test/capy-grant-permits.test.ts b/test/capy-grant-permits.test.ts new file mode 100644 index 0000000..774ebab --- /dev/null +++ b/test/capy-grant-permits.test.ts @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import { randomUUID } from "node:crypto"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { CapyBindings, CapyBindingError } from "../src/capy/bindings.js"; +import { removeCompletedWorkspaceCapyInTx } from "../src/capy/lifecycle.js"; +import { RemoteAgentConnections } from "../src/remote-agents/connections.js"; +import { encryptSecret } from "../src/crypto.js"; +import { migrate } from "../src/migrations.js"; +import { McpOAuthSessions } from "../src/mcp/oauth/sessions.js"; +import { McpOAuthConnection, McpOAuthAccessRefused } from "../src/mcp/oauth/admission.js"; +import { createMcpOAuthProtocol } from "../src/mcp/oauth/protocol.js"; +import { revokeMcpOAuthGrantRows } from "../src/mcp/oauth/revocations.js"; +import { relayService } from "../src/mcp/relay-participants.js"; +import { buildMcpServer } from "../src/mcp/server.js"; +import type { McpContext } from "../src/mcp/context.js"; +import { PGliteDatabase } from "./support/harness.js"; + +const ORIGIN="https://relay.example.test", SHARED="/api/v1/mcp/shared-sessions", RESOURCE=ORIGIN+"/cloud"+SHARED; +const MISSING="No hosted participant with that name is owned by you in this workspace."; +async function fixture(t:TestContext) { + const database=new PGliteDatabase();t.after(()=>database.close());await migrate(database); + const keyring={active:{id:"fake",key:Buffer.alloc(32,7)}}; + const actor={userId:randomUUID(),organizationId:randomUUID(),workspaceId:randomUUID()}; + const member=randomUUID(),otherWorkspace=randomUUID(); + await database.query("INSERT INTO organizations(id,name) VALUES ($1,'Org')",[actor.organizationId]); + for(const [id,role] of [[actor.userId,"owner"],[member,"member"]]) { + await database.query("INSERT INTO users(id,email,name) VALUES ($1,$2,'Fake User')",[id,`${id}@example.test`]); + await database.query("INSERT INTO organization_memberships(organization_id,user_id,role) VALUES ($1,$2,$3)",[actor.organizationId,id,role]); + } + for(const id of [actor.workspaceId,otherWorkspace]) await database.query(`INSERT INTO workspaces + (id,organization_id,name,relaycast_workspace_id,relaycast_api_key_ciphertext) VALUES($1,$2,'Workspace',$3,$4)`, + [id,actor.organizationId,id===actor.workspaceId?"rc-primary":"rc-other",encryptSecret("rk-primary",keyring.active)]); + await new RemoteAgentConnections(database,keyring,async()=>Response.json({items:[]})).connect(actor,"capy",{key:"capy_"+"s".repeat(32)}); + const agents=new Map(),writes:any[]=[];let registrations=0,reads=0,remoteChecks=0; + let onAddress:(()=>Promise)|undefined,onAuthenticated:(()=>Promise)|undefined; + let unavailable=false; + const roster=(a:any)=>({...a,address:null,kind:"agent",persona:null,status:"active",lastSeen:null}); + const client={ + getRosterAgent:async(_key:string,name:string)=>agents.has(name)?roster(agents.get(name)):null, + getAgent:async(_key:string,name:string)=>agents.has(name)?roster(agents.get(name)):null, + registerHosted:async(_key:string,name:string,metadata:Record)=>{ + registrations++;const a={id:`ag_${randomUUID()}`,name,metadata,token:`native-${randomUUID()}`};agents.set(name,a);return a; + }, + recoverHosted:async()=>{assert.fail("permit must never recover a remote");}, + authenticatedAgent:async(token:string)=>{remoteChecks++;if(unavailable)throw new Error("private provider secret"); + const a=[...agents.values()].find(a=>a.token===token);assert.ok(a);await onAuthenticated?.();return {...roster(a),workspaceId:"rc-primary",channels:[]};}, + releaseHosted:async()=>undefined, + listAgents:async()=>{await onAddress?.();return [roster({id:"ag_peer",name:"peer",metadata:{}}),...[...agents.values()].map(roster)];}, + heartbeat:async()=>undefined, + sendDirectMessage:async(input:any)=>{writes.push(input);return {message:{id:"12"},conversationId:"dm"};}, + replyToMessage:async(input:any)=>{writes.push(input);return {message:{id:"13"},conversationId:"dm"};}, + listConversations:async()=>[{id:"dm",participants:[binding.agent,"peer"]}], + listMessages:async()=>({messages:[{id:"10"}]}), + }; + const bindings=new CapyBindings({database,keyring,relaycast:client,runtime:{getThread:async(_key,id)=>{reads++;return {id,title:null,status:"active"};}}}); + const binding=await bindings.bind(actor,"jam_owned000"); + const protocol=createMcpOAuthProtocol({publicOrigin:ORIGIN,enabledResources:[SHARED]}); + const sessions=new McpOAuthSessions(database,{protocol,tenancyMode:"multi"}); + const clientId=`mcpc_${randomUUID()}`; + await database.query("INSERT INTO mcp_oauth_clients(client_id,redirect_uris) VALUES ($1,$2)",[clientId,[ORIGIN+"/cb"]]); + const grant=async(owner=actor)=>{ + const grantId=randomUUID(),tokenFamilyId=randomUUID(); + await database.query(`INSERT INTO mcp_oauth_grants + (id,client_id,user_id,organization_id,workspace_id,agent_name,agent_remote_id,scopes,resource,token_family_id) + VALUES($1,$2,$3,$4,$5,'approved-persona','ag_persona',$6,$7,$8)`, + [grantId,clientId,owner.userId,owner.organizationId,owner.workspaceId,["relay:mcp"],RESOURCE,tokenFamilyId]); + const pair=await database.transaction(tx=>sessions.issue(tx,{...owner,grantId,tokenFamilyId,scopes:["relay:mcp"]})); + const access=await sessions.resolveAccessToken(pair.accessToken,RESOURCE);assert.ok(access); + const connection=new McpOAuthConnection(access,pair.accessToken,{tenancyMode:"multi",requiredScope:"relay:mcp"}); + return {connection,pair,grantId,owner,tokenFamilyId}; + }; + const context=(connection:McpOAuthConnection,extra:Partial={}):McpContext=>({ + principal:connection.access.principal,oauth:connection,authMode:"oauth",role:"owner",origin:ORIGIN,endpointUrl:RESOURCE, + workspace:{id:actor.workspaceId,organizationId:actor.organizationId,name:"Workspace",role:"owner",relaycastWorkspaceId:"rc-primary",relaycastApiKey:"rk-primary"}, + desktopCall:{},members:async()=>[],providers:async()=>[],readHistory:async()=>{assert.fail("no history");}, + relaycast:{listAgents:async()=>[],listPresence:async()=>[]}, + relay:{database,keyring,client,sessionId:undefined},capyPermits:bindings,...extra, + } as McpContext); + const permits=()=>database.query("SELECT * FROM capy_grant_permits ORDER BY created_at,id"); + const revoke=(id:string)=>database.transaction(tx=>revokeMcpOAuthGrantRows(tx,id,"user_revoked",new Date())); + const mcp=async(ctx:McpContext)=>{ + const server=buildMcpServer(ctx),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(b); + const sdk=new Client({name:"capy-permit-test",version:"1"});await sdk.connect(a); + t.after(async()=>{await sdk.close();await server.close();});return sdk; + }; + return {database,keyring,actor,member,otherWorkspace,bindings,binding,agents,writes,grant,context,permits,revoke,sessions,mcp, + counts:()=>({registrations,reads,remoteChecks}),onAddress:(fn:typeof onAddress)=>{onAddress=fn;}, + onAuthenticated:(fn:typeof onAuthenticated)=>{onAuthenticated=fn;},unavailable:(value:boolean)=>{unavailable=value;}}; +} + +test("Capy explicit grant permits preserve binding custody and the OAuth persona",async t=>{ + const e=await fixture(t),g=await e.grant(),second=await e.grant(),name=e.binding.agent; + await t.test("same person alone grants no send authority; self-bind is explicit, existing-only and idempotent",async()=>{ + await assert.rejects(relayService(e.context(g.connection)).send({to:"peer",text:"no",as:name}),/approved/); + const before=e.counts(); + await assert.rejects(e.bindings.bindGrant(g.connection,"jam_absent00"),{code:"capy_binding_not_found"}); + assert.equal((await e.permits()).rows.length,0); + const first=await e.bindings.bindGrant(g.connection,"jam_owned000"); + assert.deepEqual(first,{...e.binding,already_bound:true}); + assert.deepEqual(await e.bindings.bindGrant(g.connection,"jam_owned000"),first); + assert.equal((await e.permits()).rows.length,1); + assert.deepEqual(e.counts(),before,"bind permit performs no provider/Relay reads or writes"); + assert.equal(JSON.stringify(first).includes(e.agents.get(name).token),false); + }); + await t.test("only the exact grant sends and replies; no pin, participant or other identity rights",async()=>{ + await assert.rejects(relayService(e.context(second.connection)).send({to:"peer",text:"no",as:name}),/approved/); + const result=await relayService(e.context(g.connection)).send({to:"peer",text:"yes",as:name,idempotency_key:"same-key"}); + assert.equal(result.from,name);assert.equal(e.writes[0].token,e.agents.get(name).token); + assert.equal(JSON.stringify(result).includes(e.agents.get(name).token),false); + await relayService(e.context(g.connection)).send({to:"peer",text:"reply",as:name,reply_to:"10"}); + const service=relayService(e.context(g.connection)); + await assert.rejects(service.read({agent:"peer",as:name,limit:10}),/approved/); + await assert.rejects(service.inbox(name,[]),/approved/); + await assert.rejects(service.subscribeChannel({channel:"ops",as:name}),/approved/); + await assert.rejects(service.join({name}),/approved/); + assert.equal((await e.database.query("SELECT * FROM hosted_relay_participants")).rows.length,0); + const grant=(await e.database.query("SELECT agent_name,agent_remote_id FROM mcp_oauth_grants WHERE id=$1",[g.grantId])).rows[0]; + assert.deepEqual(grant,{agent_name:"approved-persona",agent_remote_id:"ag_persona"}); + assert.equal(g.connection.access.agentName,"approved-persona");assert.equal(e.writes.length,2); + }); + await t.test("explicit unbind only revokes its own permit, never native injection or another grant",async()=>{ + await e.bindings.bindGrant(second.connection,"jam_owned000"); + assert.deepEqual(await e.bindings.unbindGrant(g.connection,"jam_owned000"),{revoked:true}); + assert.deepEqual(await e.bindings.unbindGrant(g.connection,"jam_owned000"),{revoked:false}); + assert.equal(await e.bindings.agentForGrant(g.connection,name),null); + assert.ok(await e.bindings.agentForGrant(second.connection,name)); + assert.ok(await e.bindings.agentFor(e.actor.workspaceId,e.actor.userId,name)); + assert.equal((await e.database.query("SELECT state,generation FROM capy_bindings WHERE id=$1",[e.binding.id])).rows[0]?.state,"bound"); + await e.bindings.bindGrant(g.connection,"jam_owned000"); + assert.equal((await e.permits()).rows.filter(p=>p.grant_id===g.grantId&&p.revoked_at===null).length,1); + }); + await t.test("grant revocation and its permit revoke atomically, including rollback",async()=>{ + await assert.rejects(e.database.transaction(async tx=>{await revokeMcpOAuthGrantRows(tx,g.grantId,"test",new Date());throw new Error("rollback");}),/rollback/); + assert.ok(await e.bindings.agentForGrant(g.connection,name)); + await e.revoke(g.grantId); + assert.equal((await e.permits()).rows.some(p=>p.grant_id===g.grantId&&p.revoked_at===null),false); + await assert.rejects(e.bindings.agentForGrant(g.connection,name),McpOAuthAccessRefused); + assert.ok(await e.bindings.agentForGrant(second.connection,name)); + assert.ok(await e.bindings.agentFor(e.actor.workspaceId,e.actor.userId,name)); + }); +}); + +test("Capy permits fail closed across actor, credential and binding changes",async t=>{ + const e=await fixture(t),name=e.binding.agent; + await t.test("foreign tenants/context swaps, member role and key owner cannot create or use a permit",async()=>{ + const g=await e.grant();await e.bindings.bindGrant(g.connection,"jam_owned000"); + const member=await e.grant({...e.actor,userId:e.member}); + await assert.rejects(e.bindings.bindGrant(member.connection,"jam_owned000"),{code:"capy_binding_not_found"}); + const other=await e.grant({...e.actor,workspaceId:e.otherWorkspace}); + await assert.rejects(e.bindings.bindGrant(other.connection,"jam_owned000"),{code:"capy_binding_not_found"}); + const base=e.context(g.connection); + for(const ctx of [e.context(g.connection,{principal:{...base.principal,userId:e.member}}), + e.context(g.connection,{workspace:{...base.workspace,id:e.otherWorkspace}}), + e.context(g.connection,{workspace:{...base.workspace,organizationId:randomUUID()}}), + e.context(g.connection,{workspace:{...base.workspace,relaycastWorkspaceId:"moved"}}), + e.context(g.connection,{workspace:{...base.workspace,relaycastApiKey:"rotated"}})]) { + await assert.rejects(relayService(ctx).send({to:"peer",text:"no",as:name}),{message:MISSING}); + } + await e.database.query("UPDATE organization_memberships SET role='member' WHERE user_id=$1",[e.actor.userId]); + assert.equal(await e.bindings.agentForGrant(g.connection,name),null); + await assert.rejects(e.bindings.bindGrant(g.connection,"jam_owned000"),{code:"capy_forbidden"}); + await e.database.query("UPDATE organization_memberships SET role='owner' WHERE user_id=$1",[e.actor.userId]); + await e.database.query("UPDATE remote_agent_connections SET created_by_user_id=$1 WHERE workspace_id=$2",[e.member,e.actor.workspaceId]); + assert.equal(await e.bindings.agentForGrant(g.connection,name),null); + await e.database.query("UPDATE remote_agent_connections SET created_by_user_id=$1 WHERE workspace_id=$2",[e.actor.userId,e.actor.workspaceId]); + assert.equal(e.writes.length,0); + }); + await t.test("expired/rotated bearers cannot use a retained connection object",async()=>{ + for(const mode of ["expired","rotated"]){ + const g=await e.grant();await e.bindings.bindGrant(g.connection,"jam_owned000"); + if(mode==="expired") await e.database.query("UPDATE mcp_oauth_sessions SET access_token_expires_at=now()-interval '1 second' WHERE id=$1",[g.pair.sessionId]); + else { + const fresh=await e.database.transaction(tx=>e.sessions.rotate(tx,g.pair.refreshToken,{...g.owner,grantId:g.grantId,tokenFamilyId:g.tokenFamilyId})); + assert.ok(fresh); + const access=await e.sessions.resolveAccessToken(fresh.accessToken,RESOURCE);assert.ok(access); + const renewed=new McpOAuthConnection(access,fresh.accessToken,{tenancyMode:"multi",requiredScope:"relay:mcp"}); + assert.ok(await e.bindings.agentForGrant(renewed,name),"refresh retains this grant's explicit permit"); + } + await assert.rejects(e.bindings.agentForGrant(g.connection,name),McpOAuthAccessRefused); + await assert.rejects(e.bindings.bindGrant(g.connection,"jam_owned000"),McpOAuthAccessRefused); + } + }); + await t.test("remote verification outage stays an error; revocation during external verification refuses",async()=>{ + const g=await e.grant();await e.bindings.bindGrant(g.connection,"jam_owned000"); + e.unavailable(true);await assert.rejects(e.bindings.agentForGrant(g.connection,name),{code:"capy_relay_unavailable"});e.unavailable(false); + e.onAuthenticated(async()=>{e.onAuthenticated(undefined);await e.revoke(g.grantId);}); + await assert.rejects(e.bindings.agentForGrant(g.connection,name),McpOAuthAccessRefused); + }); + await t.test("pre-send metadata reads cannot cross permit revocation",async()=>{ + const g=await e.grant();await e.bindings.bindGrant(g.connection,"jam_owned000"); + let calls=0;e.onAddress(async()=>{if(++calls===2)await e.bindings.unbindGrant(g.connection,"jam_owned000");}); + await assert.rejects(relayService(e.context(g.connection)).send({to:"peer",text:"no",as:name}),{message:MISSING}); + e.onAddress(undefined);assert.equal(e.writes.length,0); + }); + await t.test("generation and immutable identity snapshots never follow successors",async()=>{ + const g=await e.grant();await e.bindings.bindGrant(g.connection,"jam_owned000"); + const old=(await e.database.query("SELECT * FROM capy_bindings WHERE id=$1",[e.binding.id])).rows[0]!; + const permit=(await e.permits()).rows.find(p=>p.grant_id===g.grantId&&p.revoked_at===null)!; + for(const [column,value] of [["binding_generation",permit.binding_generation+1],["agent_remote_id","ag_successor"]] as const){ + await e.database.query(`UPDATE capy_grant_permits SET ${column}=$2 WHERE id=$1`,[permit.id,value]); + assert.equal(await e.bindings.agentForGrant(g.connection,name),null,`permit ${column}`); + await e.database.query(`UPDATE capy_grant_permits SET ${column}=$2 WHERE id=$1`,[permit.id,permit[column]]); + } + for(const [column,value] of [["generation",old.generation+1],["agent_remote_id","ag_successor"],["agent_name","capy-successor"]] as const){ + await e.database.query(`UPDATE capy_bindings SET ${column}=$2 WHERE id=$1`,[e.binding.id,value]); + assert.equal(await e.bindings.agentForGrant(g.connection,name),null,column); + await e.database.query(`UPDATE capy_bindings SET ${column}=$2 WHERE id=$1`,[e.binding.id,old[column]]); + } + await e.bindings.retire(e.actor,e.binding.id);assert.equal(await e.bindings.agentForGrant(g.connection,name),null); + await assert.rejects(e.bindings.bindGrant(g.connection,"jam_owned000"),{code:"capy_binding_not_found"}); + }); +}); + +test("permit tenant integrity and uniqueness are enforced by SQL; completed custody cleanup remains possible",async t=>{ + const e=await fixture(t),g=await e.grant();await e.bindings.bindGrant(g.connection,"jam_owned000"); + const p=(await e.permits()).rows[0]!; + await assert.rejects(e.database.query(`INSERT INTO capy_grant_permits + (id,organization_id,workspace_id,user_id,grant_id,binding_id,binding_generation,agent_remote_id,agent_name) + SELECT $1,organization_id,workspace_id,user_id,grant_id,binding_id,binding_generation,agent_remote_id,agent_name + FROM capy_grant_permits WHERE id=$2`,[randomUUID(),p.id]),/unique constraint/); + const other=await e.grant({...e.actor,workspaceId:e.otherWorkspace}); + // Matching a different grant's tenant cannot point at this workspace's binding. + await assert.rejects(e.database.query(`INSERT INTO capy_grant_permits + (id,organization_id,workspace_id,user_id,grant_id,binding_id,binding_generation,agent_remote_id,agent_name) + VALUES($1,$2,$3,$4,$5,$6,1,'ag_fake','capy-fake')`, + [randomUUID(),e.actor.organizationId,e.otherWorkspace,e.actor.userId,other.grantId,e.binding.id]),/foreign key/); + assert.equal(await e.database.transaction(tx=>removeCompletedWorkspaceCapyInTx(tx,e.actor.organizationId,e.actor.workspaceId)),false); + await e.bindings.retire(e.actor,e.binding.id); + assert.equal(await e.database.transaction(tx=>removeCompletedWorkspaceCapyInTx(tx,e.actor.organizationId,e.actor.workspaceId)),true); + assert.equal((await e.permits()).rows.length,0,"no orphan permits after completed native cleanup"); +}); + +test("optional MCP permit tools advertise only explicit OAuth send authority and no secrets",async t=>{ + const e=await fixture(t),g=await e.grant(),ctx=e.context(g.connection),sdk=await e.mcp(ctx); + const tools=(await sdk.listTools()).tools.map(tool=>tool.name); + assert.ok(tools.includes("bind_capy_thread"));assert.ok(tools.includes("unbind_capy_thread"));assert.ok(!tools.includes("spawn_capy_thread")); + const capabilities:any=await sdk.callTool({name:"describe_capabilities",arguments:{}}); + assert.equal(capabilities.structuredContent.groups.find((group:any)=>group.id==="capy_binding").status,"available"); + const result:any=await sdk.callTool({name:"bind_capy_thread",arguments:{thread_id:"jam_owned000"}}); + assert.equal(result.isError,undefined);assert.equal(JSON.parse(result.content[0].text).already_bound,true); + assert.equal(JSON.stringify(result).includes(e.agents.get(e.binding.agent).token),false); + const sent:any=await sdk.callTool({name:"send_relay_message",arguments:{to:"peer",text:"mcp reply",as:e.binding.agent,idempotency_key:"stable-001"}}); + assert.equal(sent.isError,undefined);assert.equal(e.writes.length,1); + const removed:any=await sdk.callTool({name:"unbind_capy_thread",arguments:{thread_id:"jam_owned000"}}); + assert.equal(removed.isError,undefined);assert.equal(JSON.parse(removed.content[0].text).revoked,true); + const denied:any=await sdk.callTool({name:"send_relay_message",arguments:{to:"peer",text:"no",as:e.binding.agent}}); + assert.equal(denied.isError,true);assert.equal(e.writes.length,1); + const bad:any=await sdk.callTool({name:"bind_capy_thread",arguments:{thread_id:"jam_absent00"}});assert.equal(bad.isError,true); + const off=await e.mcp({...ctx,capyPermits:undefined}); + assert.ok(!(await off.listTools()).tools.some(tool=>tool.name==="bind_capy_thread")); + const native=await e.mcp({...ctx,oauth:undefined,authMode:"cli_login"}); + assert.ok(!(await native.listTools()).tools.some(tool=>tool.name==="bind_capy_thread")); + const swapped=await e.mcp({...ctx,workspace:{...ctx.workspace,id:e.otherWorkspace}}); + const refusal:any=await swapped.callTool({name:"bind_capy_thread",arguments:{thread_id:"jam_owned000"}});assert.equal(refusal.isError,true); +});