diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index 487df4a2d7a..2d7494c90b4 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -27,12 +27,17 @@ function createHarness(config: Record) { return { command, runtime }; } -function createCommandContext(args: string, channel: string = "discord") { +function createCommandContext( + args: string, + channel: string = "discord", + gatewayClientScopes?: string[], +) { return { args, channel, channelId: channel, isAuthorizedSender: true, + gatewayClientScopes, commandBody: args ? `/voice ${args}` : "/voice", config: {}, requestConversationBinding: vi.fn(), @@ -200,6 +205,87 @@ describe("talk-voice plugin", () => { }); }); + it("rejects /voice set from gateway client with only operator.write scope", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const result = await command.handler( + createCommandContext("set Claudia", "webchat", ["operator.write"]), + ); + + expect(result.text).toContain("requires operator.admin"); + expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("allows /voice set from gateway client with operator.admin scope", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const result = await command.handler( + createCommandContext("set Claudia", "webchat", ["operator.admin"]), + ); + + expect(runtime.config.writeConfigFile).toHaveBeenCalled(); + expect(result.text).toContain("voice-a"); + }); + + it("rejects /voice set from webchat channel with no scopes (TUI/internal)", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + // gatewayClientScopes omitted — simulates internal webchat session without scopes + const result = await command.handler(createCommandContext("set Claudia", "webchat")); + + expect(result.text).toContain("requires operator.admin"); + expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("allows /voice set from non-gateway channels without scope check", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const result = await command.handler(createCommandContext("set Claudia", "telegram")); + + expect(runtime.config.writeConfigFile).toHaveBeenCalled(); + expect(result.text).toContain("voice-a"); + }); + it("returns provider lookup errors cleanly", async () => { const { command, runtime } = createHarness({ talk: { diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index d0916ea6b99..668a1a784b4 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -164,6 +164,14 @@ export default definePluginEntry({ } if (action === "set") { + // Persistent config writes require operator.admin for gateway clients. + // Without this check, a caller with only operator.write could bypass the + // admin-only config.patch RPC by reaching writeConfigFile indirectly + // through chat.send → /voice set. + if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) { + return { text: `⚠️ ${commandLabel} set requires operator.admin for gateway clients.` }; + } + const query = tokens.slice(1).join(" ").trim(); if (!query) { return { text: `Usage: ${commandLabel} set ` };