diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 1a2cb97..8e5819f 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -31,6 +31,7 @@ "@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0", "@modelcontextprotocol/sdk": "^1.27.1", + "@mosaicstack/agent": "workspace:^", "@mosaicstack/auth": "workspace:^", "@mosaicstack/brain": "workspace:^", "@mosaicstack/config": "workspace:^", diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts new file mode 100644 index 0000000..8541554 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; +import { + RuntimeProviderService, + type RuntimeAuditEvent, + type RuntimeAuditSink, + type RuntimeApprovalVerifier, +} from '../runtime-provider-registry.service.js'; + +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' }; +const CONTEXT = { + actorScope: OWNER_SCOPE, + channelId: 'cli', + correlationId: 'correlation-1', +}; + +class RecordingRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'fleet'; + readonly receivedScopes: RuntimeScope[] = []; + readonly sentMessages: RuntimeMessage[] = []; + terminateCalls = 0; + throwAfterSend = false; + + constructor(private readonly supported: RuntimeCapability[]) {} + + async capabilities(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { supported: this.supported }; + } + + async health(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async getSessionTree(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + this.receivedScopes.push(scope); + return; + } + + async sendMessage( + _sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + this.sentMessages.push(message); + if (this.throwAfterSend) { + throw new Error('provider acknowledgement failed'); + } + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + return { + attachmentId: 'attachment-1', + sessionId, + mode, + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + } + + async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + this.terminateCalls += 1; + } +} + +class RecordingAuditSink implements RuntimeAuditSink { + readonly events: RuntimeAuditEvent[] = []; + + async record(event: RuntimeAuditEvent): Promise { + this.events.push(event); + } +} + +class DenyingApprovalVerifier implements RuntimeApprovalVerifier { + async consume(): Promise { + return false; + } +} + +class AcceptingApprovalVerifier implements RuntimeApprovalVerifier { + consumedAction: Parameters[1] | undefined; + + async consume( + _approvalRef: string, + action: Parameters[1], + ): Promise { + this.consumedAction = action; + return true; + } +} + +function makeService( + provider: RecordingRuntimeProvider, + audit: RuntimeAuditSink = new RecordingAuditSink(), + approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(), +): RuntimeProviderService { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(provider); + return new RuntimeProviderService(registry, audit, approval); +} + +describe('RuntimeProviderService security boundary', (): void => { + it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ); + + const providerScope = provider.receivedScopes[0]; + expect(providerScope).toEqual({ + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(Object.isFrozen(providerScope)).toBe(true); + expect(audit.events).toContainEqual({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + resourceId: 'session-1', + }); + expect(JSON.stringify(audit.events)).not.toContain('hello'); + expect(JSON.stringify(audit.events)).not.toContain('key-1'); + }); + + it('fails closed before a provider side effect when a capability is missing', async (): Promise => { + const provider = new RecordingRuntimeProvider([]); + const service = makeService(provider); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/capability denied/); + expect(provider.sentMessages).toEqual([]); + }); + + it('requires a consumed exact-action approval before termination', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new DenyingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await expect( + service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT), + ).rejects.toThrow(/approval denied/); + expect(provider.terminateCalls).toBe(0); + }); + + it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new AcceptingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT); + + expect(approval.consumedAction).toEqual({ + providerId: 'fleet', + sessionId: 'session-1', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(provider.terminateCalls).toBe(1); + }); + + it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const unavailableAudit: RuntimeAuditSink = { + async record(): Promise { + throw new Error('audit unavailable'); + }, + }; + const service = makeService(provider, unavailableAudit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/audit unavailable/); + expect(provider.sentMessages).toEqual([]); + }); + + it('records a provider error after invocation as failed rather than denied', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + provider.throwAfterSend = true; + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/provider acknowledgement failed/); + expect(provider.sentMessages).toHaveLength(1); + expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([ + 'requested', + 'failed', + ]); + }); + + it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + let auditCalls = 0; + const audit: RuntimeAuditSink = { + async record(): Promise { + auditCalls += 1; + if (auditCalls === 2) { + throw new Error('completion audit unavailable'); + } + }, + }; + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).resolves.toBeUndefined(); + expect(provider.sentMessages).toHaveLength(1); + expect(auditCalls).toBe(2); + }); +}); diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 94b9fa3..3d789ae 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -13,6 +14,14 @@ import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; import { GCModule } from '../gc/gc.module.js'; +import { + AGENT_RUNTIME_PROVIDER_REGISTRY, + DenyRuntimeApprovalVerifier, + RUNTIME_APPROVAL_VERIFIER, + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderAuditService, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; @Global() @Module({ @@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + { + provide: AGENT_RUNTIME_PROVIDER_REGISTRY, + useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), + }, + RuntimeProviderAuditService, + { + provide: RUNTIME_PROVIDER_AUDIT_SINK, + useExisting: RuntimeProviderAuditService, + }, + DenyRuntimeApprovalVerifier, + { + provide: RUNTIME_APPROVAL_VERIFIER, + useExisting: DenyRuntimeApprovalVerifier, + }, + RuntimeProviderService, AgentService, ], controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], @@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + RuntimeProviderService, + AGENT_RUNTIME_PROVIDER_REGISTRY, ], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts new file mode 100644 index 0000000..89617e5 --- /dev/null +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -0,0 +1,404 @@ +import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; + +export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY'); +export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK'); +export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER'); + +export type RuntimeProviderOperation = + | RuntimeCapability + | 'runtime.capabilities' + | 'runtime.health'; +export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; + +/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */ +export interface RuntimeProviderRequestContext { + actorScope: ActorTenantScope; + channelId: string; + correlationId: string; +} + +/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */ +export interface RuntimeAuditEvent { + providerId: string; + operation: RuntimeProviderOperation; + outcome: RuntimeProviderAuditOutcome; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + resourceId?: string; +} + +export interface RuntimeAuditSink { + record(event: RuntimeAuditEvent): Promise; +} + +/** Exact action shape that a durable approval implementation must consume once. */ +export interface RuntimeTerminationAction { + providerId: string; + sessionId: string; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; +} + +export interface RuntimeApprovalVerifier { + consume(approvalRef: string, action: RuntimeTerminationAction): Promise; +} + +class RuntimeApprovalDeniedError extends Error { + constructor() { + super('Runtime termination approval denied'); + } +} + +/** + * The default denies all runtime termination until a durable, exact-action + * approval implementation is configured. This is safer than a permissive stub. + */ +@Injectable() +export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier { + async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise { + return false; + } +} + +/** + * Temporary metadata-only audit sink. M1 observability can replace this token + * with a durable audit writer without changing provider call sites. + */ +@Injectable() +export class RuntimeProviderAuditService implements RuntimeAuditSink { + private readonly logger = new Logger(RuntimeProviderAuditService.name); + + async record(event: RuntimeAuditEvent): Promise { + this.logger.log(JSON.stringify(event)); + } +} + +@Injectable() +export class RuntimeProviderService { + private readonly logger = new Logger(RuntimeProviderService.name); + + constructor( + @Inject(AGENT_RUNTIME_PROVIDER_REGISTRY) + private readonly registry: AgentRuntimeProviderRegistry, + @Inject(RUNTIME_PROVIDER_AUDIT_SINK) + private readonly audit: RuntimeAuditSink, + @Inject(RUNTIME_APPROVAL_VERIFIER) + private readonly approvals: RuntimeApprovalVerifier, + ) {} + + async capabilities( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.capabilities', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.capabilities(scope), + ); + } + + async health(providerId: string, context: RuntimeProviderRequestContext): Promise { + return this.execute( + providerId, + 'runtime.health', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.health(scope), + ); + } + + async listSessions( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.list', + 'session.list', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.listSessions(scope), + ); + } + + async getSessionTree( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.tree', + 'session.tree', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.getSessionTree(scope), + ); + } + + streamSession( + providerId: string, + sessionId: string, + cursor: string | undefined, + context: RuntimeProviderRequestContext, + ): AsyncIterable { + return this.stream( + providerId, + 'session.stream', + 'session.stream', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable => + provider.streamSession(sessionId, cursor, scope), + ); + } + + async sendMessage( + providerId: string, + sessionId: string, + message: RuntimeMessage, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.send', + 'session.send', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.sendMessage(sessionId, message, scope), + ); + } + + async attach( + providerId: string, + sessionId: string, + mode: RuntimeAttachMode, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.attach', + 'session.attach', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.attach(sessionId, mode, scope), + ); + } + + async detach( + providerId: string, + attachmentId: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.attach', + 'session.attach', + attachmentId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.detach(attachmentId, scope), + ); + } + + async terminate( + providerId: string, + sessionId: string, + approvalRef: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.terminate', + 'session.terminate', + sessionId, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => { + const approved = await this.approvals.consume(approvalRef, { + providerId, + sessionId, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + }); + if (!approved) { + throw new RuntimeApprovalDeniedError(); + } + await provider.terminate(sessionId, approvalRef, scope); + }, + ); + } + + private async execute( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability | undefined, + resourceId: string | undefined, + context: RuntimeProviderRequestContext, + invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise, + ): Promise { + const scope = this.deriveScope(context); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + if (requiredCapability) { + await this.assertCapability(provider, requiredCapability, scope); + } + invocationStarted = true; + const result = await invoke(provider, scope); + await this.recordCompletion(providerId, operation, scope, resourceId); + return result; + } catch (error: unknown) { + if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) { + await this.recordFailure(providerId, operation, scope, resourceId); + } else { + await this.record(providerId, operation, 'denied', scope, resourceId); + } + throw error; + } + } + + private async *stream( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability, + resourceId: string, + context: RuntimeProviderRequestContext, + invoke: ( + provider: AgentRuntimeProvider, + scope: RuntimeScope, + ) => AsyncIterable, + ): AsyncIterable { + const scope = this.deriveScope(context); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + await this.assertCapability(provider, requiredCapability, scope); + invocationStarted = true; + for await (const event of invoke(provider, scope)) { + yield event; + } + await this.recordCompletion(providerId, operation, scope, resourceId); + } catch (error: unknown) { + if (invocationStarted) { + await this.recordFailure(providerId, operation, scope, resourceId); + } else { + await this.record(providerId, operation, 'denied', scope, resourceId); + } + throw error; + } + } + + private provider(providerId: string): AgentRuntimeProvider { + try { + return this.registry.require(providerId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Runtime provider is not registered'; + throw new NotFoundException(message); + } + } + + private async assertCapability( + provider: AgentRuntimeProvider, + requiredCapability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + const capabilities = await provider.capabilities(scope); + if (!capabilities.supported.includes(requiredCapability)) { + throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`); + } + } + + private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope { + const actorId = context.actorScope.userId.trim(); + const tenantId = context.actorScope.tenantId.trim(); + const channelId = context.channelId.trim(); + const correlationId = context.correlationId.trim(); + if (!actorId || !tenantId || !channelId || !correlationId) { + throw new ForbiddenException( + 'Authenticated runtime actor scope and correlation are required', + ); + } + return Object.freeze({ actorId, tenantId, channelId, correlationId }); + } + + private async recordFailure( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + try { + await this.record(providerId, operation, 'failed', scope, resourceId); + } catch { + this.logger.error( + `Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async recordCompletion( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + try { + await this.record(providerId, operation, 'succeeded', scope, resourceId); + } catch { + this.logger.error( + `Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async record( + providerId: string, + operation: RuntimeProviderOperation, + outcome: RuntimeProviderAuditOutcome, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + await this.audit.record({ + providerId, + operation, + outcome, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + ...(resourceId ? { resourceId } : {}), + }); + } +} diff --git a/docs/scratchpads/tess-m1-002-provider-registry.md b/docs/scratchpads/tess-m1-002-provider-registry.md new file mode 100644 index 0000000..f9d6c67 --- /dev/null +++ b/docs/scratchpads/tess-m1-002-provider-registry.md @@ -0,0 +1,38 @@ +# TESS-M1-002 — Provider Registry + +- **Issue:** #707 +- **Branch:** `feat/tess-provider-registry` +- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events. + +## Plan + +1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract. +2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure. +3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits. +4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates. + +## Security Invariants + +- Caller-supplied actor/tenant identity never reaches runtime providers. +- Provider capability absence and approval/audit failure deny before side effects. +- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context. +- Audit events retain correlation and authority metadata but never message content or approval material. + +## Progress + +- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed. +- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation. +- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary. +- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable. +- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`. + +## Tests + +- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected. +- Focused: package registry 2 tests and gateway security boundary 7 tests pass. +- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed. +- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped). + +## Risks / Blockers + +- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 180cf0a..36472c4 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -37,6 +37,12 @@ Required operations: Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. +### M1 Registry Boundary + +`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references. + +Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary. + ## Authority Model | Intent | Owner | Tess behavior | diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0c18d5d..409ae61 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1 +1,3 @@ export const VERSION = '0.0.0'; + +export * from './runtime-provider-registry.js'; diff --git a/packages/agent/src/runtime-provider-registry.test.ts b/packages/agent/src/runtime-provider-registry.test.ts new file mode 100644 index 0000000..4e88f08 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js'; + +class TestRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'test-runtime'; + + async capabilities(_scope: RuntimeScope): Promise { + return { supported: ['session.list'] }; + } + + async health(_scope: RuntimeScope): Promise { + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(_scope: RuntimeScope): Promise { + return []; + } + + async getSessionTree(_scope: RuntimeScope): Promise { + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + _scope: RuntimeScope, + ): AsyncIterable { + return; + } + + async sendMessage( + _sessionId: string, + _message: RuntimeMessage, + _scope: RuntimeScope, + ): Promise {} + + async attach( + _sessionId: string, + _mode: RuntimeAttachMode, + _scope: RuntimeScope, + ): Promise { + return { + attachmentId: 'attachment-1', + sessionId: 'session-1', + mode: 'read', + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, _scope: RuntimeScope): Promise {} + + async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise {} +} + +describe('AgentRuntimeProviderRegistry', (): void => { + it('resolves only registered runtime providers', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect(registry.get(provider.id)).toBe(provider); + expect(registry.get('unknown-runtime')).toBeUndefined(); + expect(registry.list()).toEqual([provider]); + }); + + it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect((): void => { + registry.register(provider); + }).toThrow(/already registered/); + expect((): void => { + registry.require(''); + }).toThrow(/provider ID is required/); + expect((): void => { + registry.require('unknown-runtime'); + }).toThrow(/not registered/); + }); +}); diff --git a/packages/agent/src/runtime-provider-registry.ts b/packages/agent/src/runtime-provider-registry.ts new file mode 100644 index 0000000..16cb7d9 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.ts @@ -0,0 +1,40 @@ +import type { AgentRuntimeProvider } from '@mosaicstack/types'; + +/** + * Registry for runtime providers. Registration is explicit and replacement is + * forbidden so a provider identity cannot be silently hijacked at runtime. + */ +export class AgentRuntimeProviderRegistry { + private readonly providers = new Map(); + + register(provider: AgentRuntimeProvider): void { + const providerId = provider.id.trim(); + if (providerId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + if (this.providers.has(providerId)) { + throw new Error(`Runtime provider is already registered: ${providerId}`); + } + this.providers.set(providerId, provider); + } + + get(providerId: string): AgentRuntimeProvider | undefined { + return this.providers.get(providerId); + } + + require(providerId: string): AgentRuntimeProvider { + const normalizedProviderId = providerId.trim(); + if (normalizedProviderId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + const provider = this.providers.get(normalizedProviderId); + if (!provider) { + throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`); + } + return provider; + } + + list(): AgentRuntimeProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf89e42..5124acd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + '@mosaicstack/agent': + specifier: workspace:^ + version: link:../../packages/agent '@mosaicstack/auth': specifier: workspace:^ version: link:../../packages/auth