fix(gateway): require admin for chat config writes
This commit is contained in:
@@ -139,6 +139,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/security threat-model links: replace relative `.md` links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.
|
||||
- Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc.
|
||||
- iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.
|
||||
- Gateway/chat.send command scopes: require `operator.admin` for persistent `/config set|unset` writes routed through gateway chat clients while keeping `/config show` available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting.
|
||||
- iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.
|
||||
- iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.
|
||||
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
|
||||
|
||||
@@ -745,7 +745,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native
|
||||
- Override per channel: `channels.discord.commands.native` (bool or `"auto"`). `false` clears previously registered commands.
|
||||
- `channels.telegram.customCommands` adds extra Telegram bot menu entries.
|
||||
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
|
||||
- `config: true` enables `/config` (reads/writes `openclaw.json`).
|
||||
- `config: true` enables `/config` (reads/writes `openclaw.json`). For gateway `chat.send` clients, persistent `/config set|unset` writes also require `operator.admin`; read-only `/config show` stays available to normal write-scoped operator clients.
|
||||
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
|
||||
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
|
||||
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
|
||||
|
||||
@@ -149,6 +149,10 @@ Common scopes:
|
||||
- `operator.approvals`
|
||||
- `operator.pairing`
|
||||
|
||||
Method scope is only the first gate. Some slash commands reached through
|
||||
`chat.send` apply stricter command-level checks on top. For example, persistent
|
||||
`/config set` and `/config unset` writes require `operator.admin`.
|
||||
|
||||
### Caps/commands/permissions (node)
|
||||
|
||||
Nodes declare capability claims at connect time:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CommandFlagKey } from "../../config/commands.js";
|
||||
import { isCommandFlagEnabled } from "../../config/commands.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { CommandHandlerResult, HandleCommandsParams } from "./commands-types.js";
|
||||
|
||||
@@ -17,6 +18,30 @@ export function rejectUnauthorizedCommand(
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
export function requireGatewayClientScopeForInternalChannel(
|
||||
params: HandleCommandsParams,
|
||||
config: {
|
||||
label: string;
|
||||
allowedScopes: string[];
|
||||
missingText: string;
|
||||
},
|
||||
): CommandHandlerResult | null {
|
||||
if (!isInternalMessageChannel(params.command.channel)) {
|
||||
return null;
|
||||
}
|
||||
const scopes = params.ctx.GatewayClientScopes ?? [];
|
||||
if (config.allowedScopes.some((scope) => scopes.includes(scope))) {
|
||||
return null;
|
||||
}
|
||||
logVerbose(
|
||||
`Ignoring ${config.label} from gateway client missing scope: ${config.allowedScopes.join(" or ")}`,
|
||||
);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: config.missingText },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDisabledCommandReply(params: {
|
||||
label: string;
|
||||
configKey: CommandFlagKey;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
isInternalMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const COMMAND = "/approve";
|
||||
@@ -86,18 +83,13 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
|
||||
if (isInternalMessageChannel(params.command.channel)) {
|
||||
const scopes = params.ctx.GatewayClientScopes ?? [];
|
||||
const hasApprovals = scopes.includes("operator.approvals") || scopes.includes("operator.admin");
|
||||
if (!hasApprovals) {
|
||||
logVerbose("Ignoring /approve from gateway client missing operator.approvals.");
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "❌ /approve requires operator.approvals for gateway clients.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const missingScope = requireGatewayClientScopeForInternalChannel(params, {
|
||||
label: "/approve",
|
||||
allowedScopes: ["operator.approvals", "operator.admin"],
|
||||
missingText: "❌ /approve requires operator.approvals for gateway clients.",
|
||||
});
|
||||
if (missingScope) {
|
||||
return missingScope;
|
||||
}
|
||||
|
||||
const resolvedBy = buildResolvedByLabel(params);
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
setConfigOverride,
|
||||
unsetConfigOverride,
|
||||
} from "../../config/runtime-overrides.js";
|
||||
import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js";
|
||||
import {
|
||||
rejectUnauthorizedCommand,
|
||||
requireCommandFlagEnabled,
|
||||
requireGatewayClientScopeForInternalChannel,
|
||||
} from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
@@ -49,6 +53,14 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
||||
}
|
||||
|
||||
if (configCommand.action === "set" || configCommand.action === "unset") {
|
||||
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
|
||||
label: "/config write",
|
||||
allowedScopes: ["operator.admin"],
|
||||
missingText: "❌ /config set|unset requires operator.admin for gateway clients.",
|
||||
});
|
||||
if (missingAdminScope) {
|
||||
return missingAdminScope;
|
||||
}
|
||||
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
|
||||
const allowWrites = resolveChannelConfigWrites({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { updateSessionStore, type SessionEntry } from "../../config/sessions.js"
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import { typedCases } from "../../test-utils/typed-cases.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { handleCompactCommand } from "./commands-compact.js";
|
||||
@@ -590,6 +591,64 @@ describe("handleCommands /config configWrites gating", () => {
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||
});
|
||||
|
||||
it("blocks /config set from gateway clients without operator.admin", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams('/config set messages.ackReaction=":)"', cfg, {
|
||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
params.command.channel = INTERNAL_MESSAGE_CHANNEL;
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("requires operator.admin");
|
||||
});
|
||||
|
||||
it("keeps /config show available to gateway operator.write clients", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
} as OpenClawConfig;
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: { messages: { ackreaction: ":)" } },
|
||||
});
|
||||
const params = buildParams("/config show messages.ackReaction", cfg, {
|
||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
});
|
||||
params.command.channel = INTERNAL_MESSAGE_CHANNEL;
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Config messages.ackreaction");
|
||||
});
|
||||
|
||||
it("keeps /config set working for gateway operator.admin clients", async () => {
|
||||
const cfg = {
|
||||
commands: { config: true, text: true },
|
||||
} as OpenClawConfig;
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: { messages: { ackReaction: ":)" } },
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
const params = buildParams('/config set messages.ackReaction=":D"', cfg, {
|
||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
||||
GatewayClientScopes: ["operator.write", "operator.admin"],
|
||||
});
|
||||
params.command.channel = INTERNAL_MESSAGE_CHANNEL;
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledOnce();
|
||||
expect(result.reply?.text).toContain("Config updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands bash alias", () => {
|
||||
|
||||
Reference in New Issue
Block a user