mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 04:36:21 +00:00
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:
committed by
GitHub
parent
629fc2f8f0
commit
91a4635bdc
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1761,6 +1761,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
pluginName: record.name,
|
||||
pluginRoot: record.rootDir,
|
||||
allowReservedCommandNames,
|
||||
allowOwnerStatusExposure: canClaimReservedCommandOwnership(record),
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user