diff --git a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts new file mode 100644 index 0000000..94b217f --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { ChatGateway } from './chat.gateway.js'; + +const payload: SlashCommandPayload = { + command: 'gc', + conversationId: 'conversation-1', + approvalId: 'approval-1', +}; + +function buildGateway(commandExecutor: { + execute: ReturnType; + createApproval: ReturnType; +}): ChatGateway { + return new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + commandExecutor as never, + {} as never, + ); +} + +describe('ChatGateway command approval ingress', () => { + it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise => { + const commandExecutor = { + execute: vi.fn().mockResolvedValue({ ...payload, success: true }), + createApproval: vi.fn(), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, payload); + + expect(commandExecutor.execute).toHaveBeenCalledWith(payload, 'admin-1'); + expect(client.emit).toHaveBeenCalledWith( + 'command:result', + expect.objectContaining({ success: true }), + ); + }); + + it('issues a durable approval only for the authenticated actor', async (): Promise => { + const commandExecutor = { + execute: vi.fn(), + createApproval: vi.fn().mockResolvedValue({ + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandApproval(client as never, { + command: 'gc', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.createApproval).toHaveBeenCalledWith( + { command: 'gc', conversationId: 'conversation-1' }, + 'admin-1', + ); + expect(client.emit).toHaveBeenCalledWith('command:approval', { + command: 'gc', + conversationId: 'conversation-1', + success: true, + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 5528e32..c552ffe 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -15,6 +15,7 @@ import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import type { SetThinkingPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SystemReloadPayload, RoutingDecisionInfo, @@ -429,6 +430,30 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.emit('command:result', result); } + @SubscribeMessage('command:approve') + async handleCommandApproval( + @ConnectedSocket() client: Socket, + @MessageBody() payload: SlashCommandPayload, + ): Promise { + const scope = this.getClientScope(client); + const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null; + const result: SlashCommandApprovalResultPayload = approval + ? { + command: payload.command, + conversationId: payload.conversationId, + success: true, + approvalId: approval.approvalId, + expiresAt: approval.expiresAt, + } + : { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Not authorized to approve this command.', + }; + client.emit('command:approval', result); + } + broadcastReload(payload: SystemReloadPayload): void { this.server.emit('system:reload', payload); this.logger.log('Broadcasted system:reload to all connected clients'); diff --git a/apps/gateway/src/commands/command-authorization.service.spec.ts b/apps/gateway/src/commands/command-authorization.service.spec.ts index 51b25eb..7eb7034 100644 --- a/apps/gateway/src/commands/command-authorization.service.spec.ts +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -30,29 +30,32 @@ function createService(role: string): CommandAuthorizationService { describe('CommandAuthorizationService', () => { it('consumes one exact actor-bound approval once', async (): Promise => { const service = createService('admin'); - const approval = await service.createApproval('gc', payload, 'admin-1'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + expect(approval).not.toBeNull(); expect( - (await service.authorize(adminCommand, payload, 'admin-1', approval.approvalId)).allowed, + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, ).toBe(true); expect( - (await service.authorize(adminCommand, payload, 'admin-1', approval.approvalId)).allowed, + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, ).toBe(false); }); it('rejects an approval when the structured action is mutated', async (): Promise => { const service = createService('admin'); - const approval = await service.createApproval('gc', payload, 'admin-1'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); const mutated = { ...payload, conversationId: 'other-conversation' }; + expect(approval).not.toBeNull(); expect( - (await service.authorize(adminCommand, mutated, 'admin-1', approval.approvalId)).allowed, + (await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed, ).toBe(false); }); it('denies an admin command to a member before approval is considered', async (): Promise => { const service = createService('member'); - const approval = await service.createApproval('gc', payload, 'member-1'); + const approval = await service.createApproval(adminCommand, payload, 'member-1'); + expect(approval).toBeNull(); expect( - (await service.authorize(adminCommand, payload, 'member-1', approval.approvalId)).allowed, + (await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed, ).toBe(false); }); }); diff --git a/apps/gateway/src/commands/command-authorization.service.ts b/apps/gateway/src/commands/command-authorization.service.ts index f6249a4..ce7f499 100644 --- a/apps/gateway/src/commands/command-authorization.service.ts +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -55,17 +55,20 @@ export class CommandAuthorizationService { } async createApproval( - command: string, + command: CommandDef, payload: SlashCommandPayload, actorId: string, - ): Promise { + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null; + const approvalId = randomUUID(); const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); const approval: CommandApproval = { approvalId, - actionDigest: this.actionDigest(command, payload), + actionDigest: this.actionDigest(command.name, payload), actorId, - command, + command: command.name, expiresAt, }; await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300'); diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts index cd3cd09..23a3d35 100644 --- a/apps/gateway/src/commands/command-executor-tess-security.spec.ts +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; const registry = { @@ -33,7 +34,7 @@ const authorization = { ), }; -function buildExecutor(): CommandExecutorService { +function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { return new CommandExecutorService( registry as never, { getSession: vi.fn() } as never, @@ -44,10 +45,25 @@ function buildExecutor(): CommandExecutorService { null, null, null, - authorization as never, + authorizationService as never, ); } +function createDurableAuthorization(): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + describe('TESS-M1-SEC-001 command authorization abuse cases', () => { const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; @@ -70,4 +86,21 @@ describe('TESS-M1-SEC-001 command authorization abuse cases', () => { expect(result.message).toContain('approval'); expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); }); + + it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise => { + const executor = buildExecutor(createDurableAuthorization()); + + const denied = await executor.execute(payload, 'admin-1'); + const approval = await executor.createApproval(payload, 'admin-1'); + const approved = await executor.execute( + { ...payload, approvalId: approval?.approvalId }, + 'admin-1', + ); + + expect(denied.success).toBe(false); + expect(denied.message).toContain('approval'); + expect(approval).not.toBeNull(); + expect(approved.success).toBe(true); + expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index c4ed381..a7a455a 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -162,6 +162,14 @@ export class CommandExecutorService { } } + async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) { + const def = this.registry + .getManifest() + .commands.find((command) => command.name === payload.command); + if (!def || !this.authorization) return null; + return this.authorization.createApproval(def, payload, scope.userId); + } + private async handleModel( args: string | null, conversationId: string, diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 6b12ecd..432a53d 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -1,5 +1,6 @@ import type { CommandManifestPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SlashCommandResultPayload, SystemReloadPayload, @@ -116,6 +117,7 @@ export interface ServerToClientEvents { 'session:info': (payload: SessionInfoPayload) => void; 'commands:manifest': (payload: CommandManifestPayload) => void; 'command:result': (payload: SlashCommandResultPayload) => void; + 'command:approval': (payload: SlashCommandApprovalResultPayload) => void; 'system:reload': (payload: SystemReloadPayload) => void; error: (payload: ErrorPayload) => void; } @@ -125,5 +127,6 @@ export interface ClientToServerEvents { message: (data: ChatMessagePayload) => void; 'set:thinking': (data: SetThinkingPayload) => void; 'command:execute': (data: SlashCommandPayload) => void; + 'command:approve': (data: SlashCommandPayload) => void; abort: (data: AbortPayload) => void; } diff --git a/packages/types/src/commands/index.ts b/packages/types/src/commands/index.ts index 5c984df..8b46aeb 100644 --- a/packages/types/src/commands/index.ts +++ b/packages/types/src/commands/index.ts @@ -63,6 +63,18 @@ export interface SlashCommandPayload { conversationId: string; command: string; args?: string; + /** One-time server-issued approval for a privileged command execution. */ + approvalId?: string; +} + +/** Server response to a request to approve a privileged slash command. */ +export interface SlashCommandApprovalResultPayload { + conversationId: string; + command: string; + success: boolean; + approvalId?: string; + expiresAt?: string; + message?: string; } /** Server response to a slash command */