mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 09:47:17 +00:00
fix(gateway): guard plugin method scope metadata
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user