fix(talk-voice): enforce operator.admin scope on /voice set config writes (#54461)

* fix(talk-voice): enforce operator.admin scope on /voice set config writes

* fix(talk-voice): align scope guard with phone-control pattern

Use optional chaining (?.) instead of Array.isArray so webchat callers
with undefined scopes are rejected, matching the established pattern in
phone-control. Add test for webchat-with-no-scopes case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jacob Tomlinson
2026-03-25 12:55:26 -07:00
committed by GitHub
parent d81593c6e2
commit e34694733f
2 changed files with 95 additions and 1 deletions

View File

@@ -27,12 +27,17 @@ function createHarness(config: Record<string, unknown>) {
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: {

View File

@@ -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 <voiceId|name>` };