diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 8446da642db..1632dd8ddd2 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -110,7 +110,11 @@ describe("phone-control plugin", () => { await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => { expect(command.name).toBe("phone"); - const res = await command.handler(createCommandContext("arm writes 30s")); + const res = await command.handler({ + ...createCommandContext("arm writes 30s"), + channel: "webchat", + gatewayClientScopes: ["operator.admin"], + }); const text = String(res?.text ?? ""); const nodes = ( getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } @@ -139,6 +143,30 @@ describe("phone-control plugin", () => { }); }); + it("blocks external channel callers without operator.admin from mutating phone control", async () => { + await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { + const res = await command.handler({ + ...createCommandContext("arm writes 30s"), + channel: "telegram", + }); + + expect(String(res?.text ?? "")).toContain("requires operator.admin"); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + }); + + it("blocks external channel callers without operator.admin from disarming phone control", async () => { + await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { + const res = await command.handler({ + ...createCommandContext("disarm"), + channel: "telegram", + }); + + expect(String(res?.text ?? "")).toContain("requires operator.admin"); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + }); + it("allows internal operator.admin callers to mutate phone control", async () => { await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { const res = await command.handler({ @@ -151,4 +179,23 @@ describe("phone-control plugin", () => { expect(writeConfigFile).toHaveBeenCalledTimes(1); }); }); + + it("allows external channel callers with operator.admin to disarm phone control", async () => { + await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { + await command.handler({ + ...createCommandContext("arm writes 30s"), + channel: "webchat", + gatewayClientScopes: ["operator.admin"], + }); + + const res = await command.handler({ + ...createCommandContext("disarm"), + channel: "telegram", + gatewayClientScopes: ["operator.admin"], + }); + + expect(String(res?.text ?? "")).toContain("disarmed"); + expect(writeConfigFile).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 1ebc407bf6e..0f54617c3a4 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -358,9 +358,9 @@ export default definePluginEntry({ } if (action === "disarm") { - if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) { + if (!ctx.gatewayClientScopes?.includes("operator.admin")) { return { - text: "⚠️ /phone disarm requires operator.admin for internal gateway callers.", + text: "⚠️ /phone disarm requires operator.admin.", }; } const res = await disarmNow({ @@ -380,9 +380,9 @@ export default definePluginEntry({ } if (action === "arm") { - if (ctx.channel === "webchat" && !ctx.gatewayClientScopes?.includes("operator.admin")) { + if (!ctx.gatewayClientScopes?.includes("operator.admin")) { return { - text: "⚠️ /phone arm requires operator.admin for internal gateway callers.", + text: "⚠️ /phone arm requires operator.admin.", }; } const group = parseGroup(tokens[1]); diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index 6cd096fb557..982a14aa3e5 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -179,7 +179,9 @@ describe("talk-voice plugin", () => { }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); - const result = await command.handler(createCommandContext("set Claudia")); + const result = await command.handler( + createCommandContext("set Claudia", "webchat", ["operator.admin"]), + ); expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ talk: { @@ -209,7 +211,7 @@ describe("talk-voice plugin", () => { }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]); - await command.handler(createCommandContext("set Ava")); + await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"])); expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ talk: { @@ -247,10 +249,18 @@ describe("talk-voice plugin", () => { expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); }); - it("allows /voice set from non-gateway channels without scope check", async () => { + it("rejects /voice set from non-gateway channels without operator.admin", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("telegram"); const result = await run(); + expect(result.text).toContain("requires operator.admin"); + expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("allows /voice set when operator.admin is present on a non-webchat channel", async () => { + const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]); + const result = await run(); + expect(runtime.config.writeConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 668a1a784b4..402bf2863af 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -164,12 +164,11 @@ 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.` }; + // Persistent config writes require operator.admin on every channel. + // Without this check, external channel senders could bypass the + // admin-only config.patch RPC by reaching writeConfigFile indirectly. + if (!ctx.gatewayClientScopes?.includes("operator.admin")) { + return { text: `⚠️ ${commandLabel} set requires operator.admin.` }; } const query = tokens.slice(1).join(" ").trim();