Extensions: require admin for config write commands (#56002)

* Extensions: require admin for config write commands

* Tests: cover phone control disarm auth
This commit is contained in:
Jacob Tomlinson
2026-03-27 13:35:42 -07:00
committed by GitHub
parent e64a881ae0
commit aa66ae1fc7
4 changed files with 70 additions and 14 deletions

View File

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

View File

@@ -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]);

View File

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

View File

@@ -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();