Tighten phone-control mutation authorization [AI] (#87150)

* fix: require admin authorization for phone control mutations

* addressing codex review

* addressing codex review

* addressing ci

* addressing ci

* test: restore provider registry mock isolation

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-28 16:00:01 +05:30
committed by GitHub
parent 629fc2f8f0
commit 91a4635bdc
11 changed files with 209 additions and 19 deletions

View File

@@ -703,18 +703,15 @@ export const configHandlers: GatewayRequestHandlers = {
respond(true, { ok: true, path: configPath }, undefined);
} catch (error) {
const errorMessage = formatConfigOpenError(error);
const isHeadlessError = errorMessage.includes("xdg-open") && errorMessage.includes("no method available");
const isHeadlessError =
errorMessage.includes("xdg-open") && errorMessage.includes("no method available");
const detailedError = isHeadlessError
? `Cannot open file in headless environment. File path: ${configPath}. This environment appears to lack a graphical or terminal browser handler.`
: `Failed to open config file: ${errorMessage}`;
context?.logGateway?.warn(
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${errorMessage}`,
);
respond(
true,
{ ok: false, path: configPath, error: detailedError },
undefined,
);
respond(true, { ok: false, path: configPath, error: detailedError }, undefined);
}
},
};

View File

@@ -160,6 +160,12 @@ export function validatePluginCommandDefinition(
: "Command requiredScopes contains unknown operator scope";
}
}
if (
command.exposeSenderIsOwner !== undefined &&
typeof command.exposeSenderIsOwner !== "boolean"
) {
return "Command exposeSenderIsOwner must be a boolean";
}
if (command.channels !== undefined) {
if (!Array.isArray(command.channels)) {
return "Command channels must be an array of channel ids";
@@ -308,7 +314,12 @@ export function pluginCommandSupportsChannel(
export function registerPluginCommand(
pluginId: string,
command: OpenClawPluginCommandDefinition,
opts?: { pluginName?: string; pluginRoot?: string; allowReservedCommandNames?: boolean },
opts?: {
pluginName?: string;
pluginRoot?: string;
allowReservedCommandNames?: boolean;
allowOwnerStatusExposure?: boolean;
},
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (isPluginCommandRegistryLocked()) {
@@ -363,6 +374,9 @@ export function registerPluginCommand(
pluginId,
pluginName: opts?.pluginName,
pluginRoot: opts?.pluginRoot,
...(opts?.allowOwnerStatusExposure === true && normalizedCommand.exposeSenderIsOwner === true
? { trustedOwnerStatusExposure: true as const }
: {}),
});
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true };

View File

@@ -11,6 +11,7 @@ export type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
pluginId: string;
pluginName?: string;
pluginRoot?: string;
trustedOwnerStatusExposure?: true;
};
type PluginCommandState = {
@@ -59,6 +60,13 @@ export function isTrustedReservedCommandOwner(command: RegisteredPluginCommand):
return command.ownership === "reserved";
}
export function canExposeSenderIsOwner(command: RegisteredPluginCommand): boolean {
return (
(Array.isArray(command.requiredScopes) && command.requiredScopes.length > 0) ||
command.trustedOwnerStatusExposure === true
);
}
export function listRegisteredPluginCommands(): RegisteredPluginCommand[] {
return Array.from(pluginCommands.values());
}

View File

@@ -737,6 +737,109 @@ describe("registerPluginCommand", () => {
expect(observedOwnerStatus).toBeUndefined();
});
it("ignores owner status opt-in from direct plugin command registration", async () => {
let observedOwnerStatus: boolean | undefined;
registerPluginCommand("demo-plugin", {
name: "voice",
description: "Voice command",
exposeSenderIsOwner: true,
handler: async (ctx) => {
observedOwnerStatus = ctx.senderIsOwner;
return { text: "ok" };
},
});
const match = requirePluginCommandMatch("/voice");
await executePluginCommand({
command: match.command,
channel: "telegram",
isAuthorizedSender: true,
senderIsOwner: true,
commandBody: "/voice",
config: {},
});
expect(observedOwnerStatus).toBeUndefined();
});
it("ignores owner status opt-in from external plugin registry commands", async () => {
const pluginRegistry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
activateGlobalSideEffects: true,
});
let observedOwnerStatus: boolean | undefined;
pluginRegistry.registerCommand(
{
...createBundledPluginRecord("external-plugin"),
origin: "workspace",
source: "/workspace/external-plugin/index.ts",
rootDir: "/workspace/external-plugin",
},
{
name: "external",
description: "External command",
exposeSenderIsOwner: true,
handler: async (ctx) => {
observedOwnerStatus = ctx.senderIsOwner;
return { text: "ok" };
},
},
);
const match = requirePluginCommandMatch("/external");
await executePluginCommand({
command: match.command,
channel: "telegram",
isAuthorizedSender: true,
senderIsOwner: true,
commandBody: "/external",
config: {},
});
expect(observedOwnerStatus).toBeUndefined();
});
it("exposes owner status to trusted bundled plugin commands that opt in", async () => {
const pluginRegistry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
activateGlobalSideEffects: true,
});
let observedOwnerStatus: boolean | undefined;
pluginRegistry.registerCommand(createBundledPluginRecord("phone-control"), {
name: "phone",
description: "Phone command",
exposeSenderIsOwner: true,
handler: async (ctx) => {
observedOwnerStatus = ctx.senderIsOwner;
return { text: "ok" };
},
});
const match = requirePluginCommandMatch("/phone");
await executePluginCommand({
command: match.command,
channel: "telegram",
isAuthorizedSender: true,
senderIsOwner: true,
commandBody: "/phone",
config: {},
});
expect(observedOwnerStatus).toBe(true);
});
it("allows command owners to run scoped plugin commands without gateway scopes", async () => {
let observedOwnerStatus: boolean | undefined;
const handler = vi.fn(async (ctx: { senderIsOwner?: boolean }) => {

View File

@@ -22,6 +22,7 @@ import {
validatePluginCommandDefinition,
} from "./command-registration.js";
import {
canExposeSenderIsOwner,
isTrustedReservedCommandOwner,
listRegisteredPluginAgentPromptGuidance,
pluginCommands,
@@ -307,7 +308,7 @@ export async function executePluginCommand(params: {
});
const effectiveAccountId = bindingConversation?.accountId ?? params.accountId;
const senderIsOwnerForCommand =
requiredScopes.length > 0 ||
canExposeSenderIsOwner(command) ||
(isTrustedReservedCommandOwner(command) &&
command.ownership === "reserved" &&
isReservedCommandName(command.name) &&

View File

@@ -1586,6 +1586,14 @@ describe("host-hook fixture plugin contract", () => {
handler: () => ({ text: "unused" }),
}),
).toBe("Command requiredScopes contains unknown operator scope: operator.unknown");
expect(
validatePluginCommandDefinition({
name: "invalid-owner-status-fixture",
description: "Invalid owner status exposure.",
exposeSenderIsOwner: "yes" as never,
handler: () => ({ text: "unused" }),
}),
).toBe("Command exposeSenderIsOwner must be a boolean");
await expect(
executePluginCommand({

View File

@@ -1761,6 +1761,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
pluginName: record.name,
pluginRoot: record.rootDir,
allowReservedCommandNames,
allowOwnerStatusExposure: canClaimReservedCommandOwnership(record),
},
);
if (!result.ok) {

View File

@@ -2054,6 +2054,8 @@ export type OpenClawPluginCommandDefinition = {
requireAuth?: boolean;
/** Operator scopes required by gateway clients; command owners may satisfy this on chat surfaces. */
requiredScopes?: OperatorScope[];
/** Whether a trusted bundled handler needs owner status for subcommand-level authorization. */
exposeSenderIsOwner?: boolean;
/**
* Allows a bundled plugin to claim a command name that is otherwise reserved
* by core. External plugins cannot use this field.