fix(discord): bind stops to approving admin
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketGateway,
|
||||
@@ -14,6 +15,7 @@ import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import {
|
||||
verifyDiscordIngressEnvelope,
|
||||
parseDiscordInteractionBindings,
|
||||
resolveDiscordInteractionActorId,
|
||||
resolveDiscordInteractionBinding,
|
||||
type DiscordIngressEnvelope,
|
||||
type DiscordIngressPayload,
|
||||
@@ -655,8 +657,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
if (!client.data.discordService) return;
|
||||
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
||||
const actionParts = ingress?.content.match(/^\/approve\s+([^\s]+)\s+([^\s]+)$/i);
|
||||
const serviceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||
if (!ingress || !actionParts || !serviceUserId || !this.commandAuthorization) return;
|
||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||
if (!ingress || !actionParts || !tenantId || !this.commandAuthorization) return;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
ingress.guildId,
|
||||
@@ -664,14 +666,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
ingress.userId,
|
||||
'approve',
|
||||
);
|
||||
if (!binding) return;
|
||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||
if (!actorId) return;
|
||||
const providerId = actionParts[1]!;
|
||||
const sessionId = actionParts[2]!;
|
||||
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
||||
providerId: actionParts[1]!,
|
||||
sessionId: actionParts[2]!,
|
||||
actorId: serviceUserId,
|
||||
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? serviceUserId,
|
||||
providerId,
|
||||
sessionId,
|
||||
actorId,
|
||||
tenantId,
|
||||
channelId: ingress.channelId,
|
||||
correlationId: ingress.correlationId,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
providerId,
|
||||
sessionId,
|
||||
),
|
||||
agentName: binding.instanceId,
|
||||
});
|
||||
client.emit('discord:approval', {
|
||||
@@ -690,8 +700,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
if (!client.data.discordService) return;
|
||||
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
||||
const actionParts = ingress?.content.match(/^\/stop\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/i);
|
||||
const serviceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||
if (!ingress || !actionParts || !serviceUserId || !this.runtimeRegistry) return;
|
||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||
if (!ingress || !actionParts || !tenantId || !this.runtimeRegistry) return;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
ingress.guildId,
|
||||
@@ -699,18 +709,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
ingress.userId,
|
||||
'stop',
|
||||
);
|
||||
if (!binding) return;
|
||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||
if (!actorId) return;
|
||||
const providerId = actionParts[1]!;
|
||||
const sessionId = actionParts[2]!;
|
||||
|
||||
try {
|
||||
// RuntimeProviderService consumes the durable approval exactly once using this
|
||||
// server-derived context before it invokes the provider termination.
|
||||
await this.runtimeRegistry.terminate(actionParts[1]!, actionParts[2]!, actionParts[3]!, {
|
||||
// RuntimeProviderService consumes the durable approval exactly once using the
|
||||
// provisioned approving-admin identity, never the Discord service account.
|
||||
await this.runtimeRegistry.terminate(providerId, sessionId, actionParts[3]!, {
|
||||
actorScope: {
|
||||
userId: serviceUserId,
|
||||
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? serviceUserId,
|
||||
userId: actorId,
|
||||
tenantId,
|
||||
},
|
||||
channelId: ingress.channelId,
|
||||
correlationId: ingress.correlationId,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
providerId,
|
||||
sessionId,
|
||||
),
|
||||
});
|
||||
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
|
||||
} catch {
|
||||
@@ -718,6 +736,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlates the immutable termination target rather than either Discord message.
|
||||
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
|
||||
*/
|
||||
private discordRuntimeActionCorrelation(
|
||||
instanceId: string,
|
||||
ingress: DiscordIngressPayload,
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
): string {
|
||||
const target = [
|
||||
instanceId,
|
||||
ingress.guildId,
|
||||
ingress.channelId,
|
||||
ingress.conversationId,
|
||||
providerId,
|
||||
sessionId,
|
||||
];
|
||||
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
|
||||
}
|
||||
|
||||
private resolveDiscordIngress(
|
||||
client: Socket,
|
||||
envelope: DiscordIngressEnvelope,
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
verifyDiscordIngressEnvelope,
|
||||
DiscordPlugin,
|
||||
type DiscordIngressPayload,
|
||||
parseDiscordInteractionBindings,
|
||||
resolveDiscordInteractionActorId,
|
||||
resolveDiscordInteractionBinding,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
@@ -38,7 +40,12 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': role === 'admin' ? 'admin' : 'operator' },
|
||||
pairedUsers: {
|
||||
'user-001': {
|
||||
role: role === 'admin' ? 'admin' : 'operator',
|
||||
mosaicUserId: 'mosaic-admin-001',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -68,8 +75,10 @@ function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationSer
|
||||
function discordGateway(role: 'admin' | 'member'): {
|
||||
gateway: ChatGateway;
|
||||
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
||||
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
||||
} {
|
||||
const authorization = commandAuthorization(role);
|
||||
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
||||
const runtimeRegistry = {
|
||||
terminate: async (
|
||||
providerId: string,
|
||||
@@ -81,6 +90,10 @@ function discordGateway(role: 'admin' | 'member'): {
|
||||
correlationId: string;
|
||||
},
|
||||
): Promise<void> => {
|
||||
consumedActions.push({
|
||||
actorId: context.actorScope.userId,
|
||||
correlationId: context.correlationId,
|
||||
});
|
||||
const approved = await authorization.consumeRuntimeTerminationApproval(approvalId, {
|
||||
providerId,
|
||||
sessionId,
|
||||
@@ -105,14 +118,19 @@ function discordGateway(role: 'admin' | 'member'): {
|
||||
runtimeRegistry as never,
|
||||
),
|
||||
client: { data: { discordService: true }, emit: vi.fn() },
|
||||
consumedActions,
|
||||
};
|
||||
}
|
||||
|
||||
function ingressEnvelope(
|
||||
content: string,
|
||||
messageId: string,
|
||||
overrides: Partial<DiscordIngressPayload> = {},
|
||||
): ReturnType<typeof createDiscordIngressEnvelope> {
|
||||
return createDiscordIngressEnvelope(createPayload({ content, messageId }), SERVICE_TOKEN);
|
||||
return createDiscordIngressEnvelope(
|
||||
createPayload({ content, messageId, ...overrides }),
|
||||
SERVICE_TOKEN,
|
||||
);
|
||||
}
|
||||
|
||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||
@@ -129,6 +147,23 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
||||
}
|
||||
|
||||
describe('Discord ingress security', () => {
|
||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||
const [binding] = parseDiscordInteractionBindings(
|
||||
JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': 'admin' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
||||
).toEqual(binding);
|
||||
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
||||
});
|
||||
|
||||
it('binds a differently named configured interaction instance without code changes', () => {
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
[
|
||||
@@ -136,7 +171,7 @@ describe('Discord ingress security', () => {
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': 'operator' },
|
||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||
},
|
||||
],
|
||||
'guild-001',
|
||||
@@ -212,12 +247,14 @@ describe('Discord ingress security', () => {
|
||||
expect(replayProtector.size).toBe(2);
|
||||
});
|
||||
|
||||
it('mints a Discord approval and consumes it for the exact stop action', async () => {
|
||||
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client } = discordGateway('admin');
|
||||
const { gateway, client, consumedActions } = discordGateway('admin');
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve fleet runtime-1', 'approve-message'),
|
||||
ingressEnvelope('/approve fleet runtime-1', 'approve-message', {
|
||||
correlationId: 'approval-ingress-correlation',
|
||||
}),
|
||||
);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
@@ -229,12 +266,20 @@ describe('Discord ingress security', () => {
|
||||
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message'),
|
||||
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message', {
|
||||
correlationId: 'stop-ingress-correlation',
|
||||
}),
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||
correlationId: 'correlation-001',
|
||||
correlationId: 'stop-ingress-correlation',
|
||||
success: true,
|
||||
});
|
||||
expect(consumedActions).toEqual([
|
||||
{
|
||||
actorId: 'mosaic-admin-001',
|
||||
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||
@@ -264,7 +309,9 @@ describe('Discord ingress security', () => {
|
||||
const { gateway, client } = discordGateway('admin');
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve fleet runtime-1', 'replay-approve'),
|
||||
ingressEnvelope('/approve fleet runtime-1', 'replay-approve', {
|
||||
correlationId: 'replay-approval-correlation',
|
||||
}),
|
||||
);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
@@ -273,11 +320,15 @@ describe('Discord ingress security', () => {
|
||||
};
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one'),
|
||||
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one', {
|
||||
correlationId: 'replay-stop-correlation-one',
|
||||
}),
|
||||
);
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two'),
|
||||
ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two', {
|
||||
correlationId: 'replay-stop-correlation-two',
|
||||
}),
|
||||
);
|
||||
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
||||
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
||||
@@ -300,7 +351,7 @@ describe('Discord ingress security', () => {
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': 'operator' },
|
||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user