refactor: centralize gateway method policy helpers

This commit is contained in:
Peter Steinberger
2026-04-04 20:07:08 +09:00
parent d766465e38
commit bbb0b574c4
7 changed files with 80 additions and 37 deletions

View File

@@ -9,6 +9,16 @@ import {
import { listGatewayMethods } from "./server-methods-list.js";
import { coreGatewayHandlers } from "./server-methods.js";
const RESERVED_ADMIN_PLUGIN_METHOD = "config.plugin.inspect";
function setPluginGatewayMethodScope(method: string, scope: "operator.read" | "operator.write") {
const registry = createEmptyPluginRegistry();
registry.gatewayMethodScopes = {
[method]: scope,
};
setActivePluginRegistry(registry);
}
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
@@ -52,13 +62,9 @@ describe("method scope resolution", () => {
});
it("keeps reserved admin namespaces admin-only even if a plugin scope is narrower", () => {
const registry = createEmptyPluginRegistry();
registry.gatewayMethodScopes = {
"config.plugin.inspect": "operator.read",
};
setActivePluginRegistry(registry);
setPluginGatewayMethodScope(RESERVED_ADMIN_PLUGIN_METHOD, "operator.read");
expect(resolveLeastPrivilegeOperatorScopesForMethod("config.plugin.inspect")).toEqual([
expect(resolveLeastPrivilegeOperatorScopesForMethod(RESERVED_ADMIN_PLUGIN_METHOD)).toEqual([
"operator.admin",
]);
});
@@ -119,13 +125,11 @@ describe("operator scope authorization", () => {
});
it("requires admin for reserved admin namespaces even if a plugin registered a narrower scope", () => {
const registry = createEmptyPluginRegistry();
registry.gatewayMethodScopes = {
"config.plugin.inspect": "operator.read",
};
setActivePluginRegistry(registry);
setPluginGatewayMethodScope(RESERVED_ADMIN_PLUGIN_METHOD, "operator.read");
expect(authorizeOperatorScopesForMethod("config.plugin.inspect", ["operator.read"])).toEqual({
expect(
authorizeOperatorScopesForMethod(RESERVED_ADMIN_PLUGIN_METHOD, ["operator.read"]),
).toEqual({
allowed: false,
missingScope: "operator.admin",
});

View File

@@ -1,5 +1,5 @@
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js";
import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js";
export const ADMIN_SCOPE = "operator.admin" as const;
export const READ_SCOPE = "operator.read" as const;
@@ -161,8 +161,9 @@ function resolveScopedMethod(method: string): OperatorScope | undefined {
if (explicitScope) {
return explicitScope;
}
if (isReservedAdminGatewayMethod(method)) {
return ADMIN_SCOPE;
const reservedScope = resolveReservedGatewayMethodScope(method);
if (reservedScope) {
return reservedScope;
}
const pluginScope = getActivePluginRegistry()?.gatewayMethodScopes?.[method];
if (pluginScope) {

View File

@@ -74,6 +74,10 @@ function memoryPluginBody(id: string) {
return `module.exports = { id: ${JSON.stringify(id)}, kind: "memory", register() {} };`;
}
const RESERVED_ADMIN_PLUGIN_METHOD = "config.plugin.inspect";
const RESERVED_ADMIN_SCOPE_WARNING =
"gateway method scope coerced to operator.admin for reserved core namespace";
function writeBundledPlugin(params: {
id: string;
body?: string;
@@ -975,7 +979,7 @@ describe("loadOpenClawPlugins", () => {
id: "reserved-gateway-scope",
register(api) {
api.registerGatewayMethod(
"config.plugin.inspect",
${JSON.stringify(RESERVED_ADMIN_PLUGIN_METHOD)},
({ respond }) => respond(true, { ok: true }),
{ scope: "operator.read" },
);
@@ -994,12 +998,12 @@ describe("loadOpenClawPlugins", () => {
},
});
expect(Object.keys(registry.gatewayHandlers)).toContain("config.plugin.inspect");
expect(registry.gatewayMethodScopes?.["config.plugin.inspect"]).toBe("operator.admin");
expect(Object.keys(registry.gatewayHandlers)).toContain(RESERVED_ADMIN_PLUGIN_METHOD);
expect(registry.gatewayMethodScopes?.[RESERVED_ADMIN_PLUGIN_METHOD]).toBe("operator.admin");
expect(
registry.diagnostics.some((diag) =>
String(diag.message).includes(
"gateway method scope coerced to operator.admin for reserved core namespace: config.plugin.inspect",
`${RESERVED_ADMIN_SCOPE_WARNING}: ${RESERVED_ADMIN_PLUGIN_METHOD}`,
),
),
).toBe(true);

View File

@@ -9,7 +9,7 @@ import type {
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js";
import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js";
import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
@@ -434,20 +434,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return;
}
registry.gatewayHandlers[trimmed] = handler;
let effectiveScope = opts?.scope;
if (
effectiveScope &&
effectiveScope !== "operator.admin" &&
isReservedAdminGatewayMethod(trimmed)
) {
const normalizedScope = normalizePluginGatewayMethodScope(trimmed, opts?.scope);
if (normalizedScope.coercedToReservedAdmin) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: `gateway method scope coerced to operator.admin for reserved core namespace: ${trimmed}`,
});
effectiveScope = "operator.admin";
}
const effectiveScope = normalizedScope.scope;
if (effectiveScope) {
registry.gatewayMethodScopes ??= {};
registry.gatewayMethodScopes[trimmed] = effectiveScope;

View File

@@ -1992,6 +1992,13 @@ export type OpenClawPluginApi = {
registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
/** Register a native messaging channel plugin (channel capability). */
registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void;
/**
* Register a gateway RPC method for this plugin.
*
* Reserved core admin namespaces (`config.*`, `exec.approvals.*`,
* `wizard.*`, `update.*`) always normalize to `operator.admin` even if a
* narrower scope is requested.
*/
registerGatewayMethod: (
method: string,
handler: GatewayRequestHandler,

View File

@@ -0,0 +1,41 @@
export const RESERVED_ADMIN_GATEWAY_METHOD_PREFIXES = [
"exec.approvals.",
"config.",
"wizard.",
"update.",
] as const;
export const RESERVED_ADMIN_GATEWAY_METHOD_SCOPE = "operator.admin" as const;
export function isReservedAdminGatewayMethod(method: string): boolean {
return RESERVED_ADMIN_GATEWAY_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix));
}
export function resolveReservedGatewayMethodScope(
method: string,
): typeof RESERVED_ADMIN_GATEWAY_METHOD_SCOPE | undefined {
if (!isReservedAdminGatewayMethod(method)) {
return undefined;
}
return RESERVED_ADMIN_GATEWAY_METHOD_SCOPE;
}
export function normalizePluginGatewayMethodScope<TScope extends string>(
method: string,
scope: TScope | undefined,
): {
scope: TScope | typeof RESERVED_ADMIN_GATEWAY_METHOD_SCOPE | undefined;
coercedToReservedAdmin: boolean;
} {
const reservedScope = resolveReservedGatewayMethodScope(method);
if (!reservedScope || !scope || scope === reservedScope) {
return {
scope,
coercedToReservedAdmin: false,
};
}
return {
scope: reservedScope,
coercedToReservedAdmin: true,
};
}

View File

@@ -1,10 +0,0 @@
export const ADMIN_GATEWAY_METHOD_PREFIXES = [
"exec.approvals.",
"config.",
"wizard.",
"update.",
] as const;
export function isReservedAdminGatewayMethod(method: string): boolean {
return ADMIN_GATEWAY_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix));
}