Skip to content

Commit 7ffbbd8

Browse files
committed
fix: reserve admin gateway method prefixes
1 parent 86ee50b commit 7ffbbd8

7 files changed

Lines changed: 103 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
6060
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.
6161
- ACPX/Windows: preserve backslashes and absolute `.exe` paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use `cmd.exe /c`, `powershell.exe -File`, or `node <script>`. (#60689)
6262
- Providers/OpenAI Codex: treat Codex CLI auth as the canonical source, stop persisting copied Codex OAuth secrets into `auth-profiles.json`, refresh expired Codex-managed tokens back into Codex storage, and keep OpenAI WebSocket fallback/cache paths stable across transport changes.
63+
- Plugins/gateway: keep reserved admin RPC namespaces (`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`) admin-only even for plugin-defined methods, and warn when a plugin tries to register a narrower scope.
6364
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when `/Run` does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.
6465
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345)
6566
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069)

docs/plugins/sdk-overview.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ methods:
163163
| `api.registerService(service)` | Background service |
164164
| `api.registerInteractiveHandler(registration)` | Interactive handler |
165165

166+
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,
167+
`update.*`) always stay `operator.admin`, even if a plugin tries to assign a
168+
narrower gateway method scope. Prefer plugin-specific prefixes for
169+
plugin-owned methods.
170+
166171
### CLI registration metadata
167172

168173
`api.registerCli(registrar, opts?)` accepts two kinds of top-level metadata:

src/gateway/method-scopes.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ describe("method scope resolution", () => {
5050
"operator.write",
5151
]);
5252
});
53+
54+
it("keeps reserved admin namespaces admin-only even if a plugin scope is narrower", () => {
55+
const registry = createEmptyPluginRegistry();
56+
registry.gatewayMethodScopes = {
57+
"config.plugin.inspect": "operator.read",
58+
};
59+
setActivePluginRegistry(registry);
60+
61+
expect(resolveLeastPrivilegeOperatorScopesForMethod("config.plugin.inspect")).toEqual([
62+
"operator.admin",
63+
]);
64+
});
5365
});
5466

5567
describe("operator scope authorization", () => {
@@ -105,6 +117,19 @@ describe("operator scope authorization", () => {
105117
missingScope: "operator.admin",
106118
});
107119
});
120+
121+
it("requires admin for reserved admin namespaces even if a plugin registered a narrower scope", () => {
122+
const registry = createEmptyPluginRegistry();
123+
registry.gatewayMethodScopes = {
124+
"config.plugin.inspect": "operator.read",
125+
};
126+
setActivePluginRegistry(registry);
127+
128+
expect(authorizeOperatorScopesForMethod("config.plugin.inspect", ["operator.read"])).toEqual({
129+
allowed: false,
130+
missingScope: "operator.admin",
131+
});
132+
});
108133
});
109134

110135
describe("plugin approval method registration", () => {

src/gateway/method-scopes.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getActivePluginRegistry } from "../plugins/runtime.js";
2+
import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js";
23

34
export const ADMIN_SCOPE = "operator.admin" as const;
45
export const READ_SCOPE = "operator.read" as const;
@@ -149,8 +150,6 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
149150
],
150151
};
151152

152-
const ADMIN_METHOD_PREFIXES = ["exec.approvals.", "config.", "wizard.", "update."] as const;
153-
154153
const METHOD_SCOPE_BY_NAME = new Map<string, OperatorScope>(
155154
Object.entries(METHOD_SCOPE_GROUPS).flatMap(([scope, methods]) =>
156155
methods.map((method) => [method, scope as OperatorScope]),
@@ -162,13 +161,13 @@ function resolveScopedMethod(method: string): OperatorScope | undefined {
162161
if (explicitScope) {
163162
return explicitScope;
164163
}
164+
if (isReservedAdminGatewayMethod(method)) {
165+
return ADMIN_SCOPE;
166+
}
165167
const pluginScope = getActivePluginRegistry()?.gatewayMethodScopes?.[method];
166168
if (pluginScope) {
167169
return pluginScope;
168170
}
169-
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
170-
return ADMIN_SCOPE;
171-
}
172171
return undefined;
173172
}
174173

src/plugins/loader.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,47 @@ describe("loadOpenClawPlugins", () => {
964964
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed-config-path.ping");
965965
},
966966
},
967+
{
968+
label: "coerces reserved gateway method namespaces to operator.admin",
969+
run: () => {
970+
useNoBundledPlugins();
971+
const plugin = writePlugin({
972+
id: "reserved-gateway-scope",
973+
filename: "reserved-gateway-scope.cjs",
974+
body: `module.exports = {
975+
id: "reserved-gateway-scope",
976+
register(api) {
977+
api.registerGatewayMethod(
978+
"config.plugin.inspect",
979+
({ respond }) => respond(true, { ok: true }),
980+
{ scope: "operator.read" },
981+
);
982+
},
983+
};`,
984+
});
985+
986+
const registry = loadOpenClawPlugins({
987+
cache: false,
988+
workspaceDir: plugin.dir,
989+
config: {
990+
plugins: {
991+
load: { paths: [plugin.file] },
992+
allow: ["reserved-gateway-scope"],
993+
},
994+
},
995+
});
996+
997+
expect(Object.keys(registry.gatewayHandlers)).toContain("config.plugin.inspect");
998+
expect(registry.gatewayMethodScopes?.["config.plugin.inspect"]).toBe("operator.admin");
999+
expect(
1000+
registry.diagnostics.some((diag) =>
1001+
String(diag.message).includes(
1002+
"gateway method scope coerced to operator.admin for reserved core namespace: config.plugin.inspect",
1003+
),
1004+
),
1005+
).toBe(true);
1006+
},
1007+
},
9671008
{
9681009
label: "limits imports to the requested plugin ids",
9691010
run: () => {

src/plugins/registry.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from "../gateway/server-methods/types.js";
1010
import { registerInternalHook } from "../hooks/internal-hooks.js";
1111
import type { HookEntry } from "../hooks/types.js";
12+
import { isReservedAdminGatewayMethod } from "../shared/gateway-method-prefixes.js";
1213
import { resolveUserPath } from "../utils.js";
1314
import { buildPluginApi } from "./api-builder.js";
1415
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
@@ -433,9 +434,23 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
433434
return;
434435
}
435436
registry.gatewayHandlers[trimmed] = handler;
436-
if (opts?.scope) {
437+
let effectiveScope = opts?.scope;
438+
if (
439+
effectiveScope &&
440+
effectiveScope !== "operator.admin" &&
441+
isReservedAdminGatewayMethod(trimmed)
442+
) {
443+
pushDiagnostic({
444+
level: "warn",
445+
pluginId: record.id,
446+
source: record.source,
447+
message: `gateway method scope coerced to operator.admin for reserved core namespace: ${trimmed}`,
448+
});
449+
effectiveScope = "operator.admin";
450+
}
451+
if (effectiveScope) {
437452
registry.gatewayMethodScopes ??= {};
438-
registry.gatewayMethodScopes[trimmed] = opts.scope;
453+
registry.gatewayMethodScopes[trimmed] = effectiveScope;
439454
}
440455
record.gatewayMethods.push(trimmed);
441456
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const ADMIN_GATEWAY_METHOD_PREFIXES = [
2+
"exec.approvals.",
3+
"config.",
4+
"wizard.",
5+
"update.",
6+
] as const;
7+
8+
export function isReservedAdminGatewayMethod(method: string): boolean {
9+
return ADMIN_GATEWAY_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix));
10+
}

0 commit comments

Comments
 (0)