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..ac29759 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -0,0 +1,74 @@ +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, { + userId: 'admin-1', + tenantId: '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' }, + { userId: 'admin-1', tenantId: '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 new file mode 100644 index 0000000..7eb7034 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; + +const adminCommand: CommandDef = { + name: 'gc', + description: 'GC', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, +}; +const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + +function createService(role: string): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + 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('CommandAuthorizationService', () => { + it('consumes one exact actor-bound approval once', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(true); + expect( + (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(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, + ).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(adminCommand, payload, 'member-1'); + expect(approval).toBeNull(); + expect( + (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 new file mode 100644 index 0000000..ce7f499 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -0,0 +1,138 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq, users as usersTable, type Db } from '@mosaicstack/db'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; +import { COMMANDS_REDIS } from './commands.tokens.js'; + +export type CommandRole = 'admin' | 'member' | 'viewer'; + +export interface CommandApproval { + approvalId: string; + actionDigest: string; + actorId: string; + command: string; + expiresAt: string; +} + +export interface CommandAuthorizationResult { + allowed: boolean; + reason?: string; +} + +@Injectable() +export class CommandAuthorizationService { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(COMMANDS_REDIS) + private readonly redis: { + get(key: string): Promise; + set(key: string, value: string, ...args: string[]): Promise; + del(key: string): Promise; + }, + ) {} + + async authorize( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + approvalId?: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || !this.hasScope(role, command.scope)) { + return { allowed: false, reason: 'not authorized for this command scope' }; + } + if (command.scope !== 'admin') return { allowed: true }; + if (!approvalId) return { allowed: false, reason: 'durable approval is required' }; + const actionDigest = this.actionDigest(command.name, payload); + const approved = await this.consumeApproval(approvalId, actorId, actionDigest); + return approved + ? { allowed: true } + : { + allowed: false, + reason: 'approval is invalid, expired, replayed, or does not match this action', + }; + } + + async createApproval( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + ): 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.name, payload), + actorId, + command: command.name, + expiresAt, + }; + await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300'); + return approval; + } + + private async resolveRole(actorId: string): Promise { + const [user] = await this.db + .select({ role: usersTable.role }) + .from(usersTable) + .where(eq(usersTable.id, actorId)) + .limit(1); + const role = user?.role; + return role === 'admin' || role === 'member' || role === 'viewer' ? role : null; + } + + private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean { + if (role === 'admin') return true; + return role === 'member' && (scope === 'core' || scope === 'agent'); + } + + private async consumeApproval( + approvalId: string, + actorId: string, + actionDigest: string, + ): Promise { + const key = this.key(approvalId); + const encoded = await this.redis.get(key); + if (!encoded) return false; + const parsed: unknown = JSON.parse(encoded); + if ( + !this.isApproval(parsed) || + parsed.actorId !== actorId || + parsed.actionDigest !== actionDigest || + Date.parse(parsed.expiresAt) <= Date.now() + ) + return false; + return (await this.redis.del(key)) === 1; + } + + private actionDigest(command: string, payload: SlashCommandPayload): string { + return createHash('sha256') + .update( + JSON.stringify({ + command, + args: payload.args?.trim() ?? '', + conversationId: payload.conversationId, + }), + ) + .digest('hex'); + } + + private isApproval(value: unknown): value is CommandApproval { + return ( + typeof value === 'object' && + value !== null && + 'approvalId' in value && + 'actionDigest' in value && + 'actorId' in value && + 'expiresAt' in value + ); + } + + private key(approvalId: string): string { + return `tess:command-approval:${approvalId}`; + } +} diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts new file mode 100644 index 0000000..2fe5550 --- /dev/null +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -0,0 +1,109 @@ +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 = { + getManifest: vi.fn(() => ({ + version: 1, + commands: [ + { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin' as const, + execution: 'socket' as const, + available: true, + }, + ], + skills: [], + })), +}; + +const sessionGc = { + sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }), +}; + +const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' }); + +const authorization = { + authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) => + Promise.resolve( + actorId === 'member-1' + ? { allowed: false, reason: 'durable approval is required' } + : { allowed: false, reason: 'not authorized for this command scope' }, + ), + ), +}; + +function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { + return new CommandExecutorService( + registry as never, + { getSession: vi.fn() } as never, + { clear: vi.fn(), set: vi.fn() } as never, + sessionGc as never, + { set: vi.fn() } as never, + { agents: {} } as never, + null, + null, + null, + 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' }; + + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it('denies a forged admin identity and does not execute a system-wide command', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('admin-forged-by-client')); + + expect(result.success).toBe(false); + expect(result.message).toContain('not authorized'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('denies a privileged command without a server-bound durable approval', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('member-1')); + + expect(result.success).toBe(false); + 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 adminScope = scope('admin-1'); + const denied = await executor.execute(payload, adminScope); + const approval = await executor.createApproval(payload, adminScope); + const approved = await executor.execute( + { ...payload, approvalId: approval?.approvalId }, + adminScope, + ); + + 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 f7fc0cc..a7a455a 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -11,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandRegistryService } from './command-registry.service.js'; @Injectable() @@ -33,6 +34,9 @@ export class CommandExecutorService { @Optional() @Inject(McpClientService) private readonly mcpClient: McpClientService | null, + @Optional() + @Inject(CommandAuthorizationService) + private readonly authorization: CommandAuthorizationService | null = null, ) {} async execute( @@ -52,6 +56,16 @@ export class CommandExecutorService { }; } + const authorization = await this.authorization?.authorize( + def, + payload, + userId, + payload.approvalId, + ); + if (authorization && !authorization.allowed) { + return { command, conversationId, success: false, message: authorization.reason }; + } + try { switch (command) { case 'model': @@ -148,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/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index 1c3a82c..0d6c30d 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -3,6 +3,7 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { ChatModule } from '../chat/chat.module.js'; import { GCModule } from '../gc/gc.module.js'; import { ReloadModule } from '../reload/reload.module.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; import { CommandRegistryService } from './command-registry.service.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; @@ -24,6 +25,7 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; inject: [COMMANDS_QUEUE_HANDLE], }, CommandRegistryService, + CommandAuthorizationService, CommandExecutorService, ], exports: [CommandRegistryService, CommandExecutorService], diff --git a/docs/scratchpads/tess-m1-sec-001.md b/docs/scratchpads/tess-m1-sec-001.md new file mode 100644 index 0000000..b297170 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-001.md @@ -0,0 +1,27 @@ +# TESS-M1-SEC-001 — Command authorization and exact-action approval + +- Issue/milestone: #707 / M1 +- Branch: `fix/tess-command-authz` +- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007` +- Scope: `apps/gateway` only, plus required in-repo security/developer documentation. + +## Plan + +1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions. +2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny. +3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results. +4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test. +5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation. + +## Assumptions + +- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement. +- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims. + +## TDD evidence + +- Pending: abuse/authz test written and observed red before implementation. + +## Verification evidence + +- Pending. 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 */