fix(tess): enforce command authorization approvals #718

Merged
jason.woltje merged 3 commits from fix/tess-command-authz into main 2026-07-12 23:18:02 +00:00
8 changed files with 171 additions and 13 deletions
Showing only changes of commit 2a4a57d7b1 - Show all commits

View File

@@ -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<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): 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<void> => {
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<void> => {
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',
});
});
});

View File

@@ -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<void> {
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');

View File

@@ -30,29 +30,32 @@ function createService(role: string): CommandAuthorizationService {
describe('CommandAuthorizationService', () => {
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
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<void> => {
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<void> => {
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);
});
});

View File

@@ -55,17 +55,20 @@ export class CommandAuthorizationService {
}
async createApproval(
command: string,
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
): Promise<CommandApproval> {
): Promise<CommandApproval | null> {
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');

View File

@@ -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<string, string>();
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<void> => {
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();
});
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 */