fix(gateway): guard plugin method scope metadata

This commit is contained in:
Vincent Koc
2026-06-03 10:40:50 +02:00
parent 0b98aea71a
commit e87494968e
2 changed files with 161 additions and 5 deletions
+62
View File
@@ -175,6 +175,44 @@ describe("method scope resolution", () => {
).toEqual({ allowed: false, missingScope: "operator.approvals" });
});
it("skips unreadable plugin session action siblings while preserving registered scopes", () => {
const registry = createEmptyPluginRegistry();
registry.sessionActions = [
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "test",
get action() {
throw new Error("session action getter exploded");
},
} as NonNullable<typeof registry.sessionActions>[number],
{
pluginId: "scope-plugin",
pluginName: "Scope Plugin",
source: "test",
action: {
id: "approve",
requiredScopes: ["operator.approvals"],
handler: () => ({ result: { ok: true } }),
},
},
];
setActivePluginRegistry(registry);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
pluginId: "scope-plugin",
actionId: "approve",
}),
).toEqual(["operator.approvals"]);
expect(
authorizeOperatorScopesForMethod("plugins.sessionAction", ["operator.write"], {
pluginId: "scope-plugin",
actionId: "approve",
}),
).toEqual({ allowed: false, missingScope: "operator.approvals" });
});
it("falls back to broad operator scopes when a dynamic session action is not locally registered", () => {
expect(
resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", {
@@ -215,6 +253,30 @@ describe("method scope resolution", () => {
]);
});
it("skips unreadable plugin gateway method descriptor siblings", () => {
const registry = createEmptyPluginRegistry();
registry.gatewayHandlers["browser.request"] = pluginHandler;
registry.gatewayMethodDescriptors.push(
{
get name() {
throw new Error("gateway method descriptor getter exploded");
},
scope: "operator.read",
} as ReturnType<typeof createPluginGatewayMethodDescriptor>,
createPluginGatewayMethodDescriptor({
pluginId: "browser",
name: "browser.request",
handler: pluginHandler,
scope: "operator.admin",
}),
);
setActivePluginRegistry(registry);
expect(resolveLeastPrivilegeOperatorScopesForMethod("browser.request")).toEqual([
"operator.admin",
]);
});
it("keeps reserved admin namespaces admin-only even if a plugin scope is narrower", () => {
setPluginGatewayMethodScope(RESERVED_ADMIN_PLUGIN_METHOD, "operator.read");
+99 -5
View File
@@ -1,4 +1,8 @@
import { normalizeOptionalString as normalizeSessionActionParam } from "@openclaw/normalization-core/string-coerce";
import type {
PluginRegistry,
PluginSessionActionRegistryRegistration,
} from "../plugins/registry-types.js";
import { getPluginRegistryState } from "../plugins/runtime-state.js";
import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js";
import {
@@ -7,6 +11,7 @@ import {
isDynamicOperatorGatewayMethod,
resolveCoreOperatorGatewayMethodScope,
} from "./methods/core-descriptors.js";
import type { GatewayMethodDescriptor } from "./methods/descriptor.js";
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
@@ -38,6 +43,92 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
TALK_SECRETS_SCOPE,
];
type ReadResult<T> = { ok: true; value: T } | { ok: false };
function readField<T>(read: () => T): ReadResult<T> {
try {
return { ok: true, value: read() };
} catch {
return { ok: false };
}
}
function readRegistryArray(
registry: PluginRegistry | null | undefined,
read: (registry: PluginRegistry) => unknown,
): readonly unknown[] {
if (!registry) {
return [];
}
const value = readField(() => read(registry));
return value.ok && Array.isArray(value.value) ? value.value : [];
}
function readArrayLength(value: readonly unknown[]): number | null {
const length = readField(() => value.length);
return length.ok && Number.isInteger(length.value) && length.value >= 0 ? length.value : null;
}
function findPluginGatewayMethodDescriptor(
registry: PluginRegistry | null | undefined,
method: string,
): GatewayMethodDescriptor | undefined {
const descriptors = readRegistryArray(registry, (value) => value.gatewayMethodDescriptors);
const length = readArrayLength(descriptors);
if (length === null) {
return undefined;
}
let index = 0;
while (index < length) {
const descriptor = readField(() => descriptors[index] as GatewayMethodDescriptor);
const name: ReadResult<string> = descriptor.ok
? readField(() => descriptor.value.name)
: { ok: false };
if (name.ok && name.value === method) {
return descriptor.value;
}
index += 1;
}
return undefined;
}
function findPluginSessionActionRegistration(params: {
actionId: string;
pluginId: string;
registry: PluginRegistry | null | undefined;
}): PluginSessionActionRegistryRegistration | undefined {
const registrations = readRegistryArray(params.registry, (value) => value.sessionActions);
const length = readArrayLength(registrations);
if (length === null) {
return undefined;
}
let index = 0;
while (index < length) {
const registration = readField(
() => registrations[index] as PluginSessionActionRegistryRegistration,
);
const pluginId: ReadResult<string> = registration.ok
? readField(() => registration.value.pluginId)
: { ok: false };
const action: ReadResult<PluginSessionActionRegistryRegistration["action"]> = registration.ok
? readField(() => registration.value.action)
: { ok: false };
const actionId: ReadResult<string> = action.ok
? readField(() => action.value.id)
: { ok: false };
if (
pluginId.ok &&
pluginId.value === params.pluginId &&
actionId.ok &&
actionId.value === params.actionId
) {
return registration.value;
}
index += 1;
}
return undefined;
}
function resolveScopedMethod(method: string): OperatorScope | undefined {
// Core descriptors are authoritative, then reserved namespace policy, then active plugin
// descriptors. Node/dynamic sentinels are intentionally excluded from operator scopes.
@@ -49,8 +140,9 @@ function resolveScopedMethod(method: string): OperatorScope | undefined {
if (reservedScope) {
return reservedScope;
}
const pluginDescriptor = getPluginRegistryState()?.activeRegistry?.gatewayMethodDescriptors?.find(
(descriptor) => descriptor.name === method,
const pluginDescriptor = findPluginGatewayMethodDescriptor(
getPluginRegistryState()?.activeRegistry,
method,
);
const pluginScope = pluginDescriptor?.scope;
return pluginScope === "node" || pluginScope === "dynamic" ? undefined : pluginScope;
@@ -100,9 +192,11 @@ function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[]
if (!pluginId || !actionId) {
return undefined;
}
const registration = getPluginRegistryState()?.activeRegistry?.sessionActions?.find(
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
);
const registration = findPluginSessionActionRegistration({
actionId,
pluginId,
registry: getPluginRegistryState()?.activeRegistry,
});
if (!registration) {
return undefined;
}