mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:58:38 +00:00
fix(security): enforce target account configWrites
This commit is contained in:
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
||||||
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. Thanks @tdjackey.
|
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. Thanks @tdjackey.
|
||||||
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
|
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
|
||||||
|
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
|
||||||
|
|
||||||
## 2026.3.8
|
## 2026.3.8
|
||||||
|
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native
|
|||||||
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
|
- `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`). 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.
|
- `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).
|
- `channels.<provider>.configWrites` gates config mutations per channel (default: true).
|
||||||
|
- For multi-account channels, `channels.<provider>.accounts.<id>.configWrites` also gates writes that target that account (for example `/allowlist --config --account <id>` or `/config set channels.<provider>.accounts.<id>...`).
|
||||||
- `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
|
- `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.
|
- `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ Notes:
|
|||||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||||
- For full provider usage breakdown, use `openclaw status --usage`.
|
- For full provider usage breakdown, use `openclaw status --usage`.
|
||||||
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
||||||
|
- In multi-account channels, config-targeted `/allowlist --account <id>` and `/config set channels.<provider>.accounts.<id>...` also honor the target account's `configWrites`.
|
||||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
|
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
|
||||||
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
|
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
|
||||||
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
|
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getChannelDock } from "../../channels/dock.js";
|
import { getChannelDock } from "../../channels/dock.js";
|
||||||
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
|
import { authorizeConfigWrite } from "../../channels/plugins/config-writes.js";
|
||||||
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
||||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
import { normalizeChannelId } from "../../channels/registry.js";
|
import { normalizeChannelId } from "../../channels/registry.js";
|
||||||
@@ -28,6 +28,7 @@ import { resolveSignalAccount } from "../../signal/accounts.js";
|
|||||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
||||||
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||||
|
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||||
import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js";
|
import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js";
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
@@ -585,16 +586,25 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
|||||||
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
|
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
|
||||||
|
|
||||||
if (shouldUpdateConfig) {
|
if (shouldUpdateConfig) {
|
||||||
const allowWrites = resolveChannelConfigWrites({
|
const writeAuth = authorizeConfigWrite({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channelId,
|
origin: { channelId, accountId: params.ctx.AccountId },
|
||||||
accountId: params.ctx.AccountId,
|
targets: [{ channelId, accountId }],
|
||||||
|
allowBypass:
|
||||||
|
isInternalMessageChannel(params.command.channel) &&
|
||||||
|
params.ctx.GatewayClientScopes?.includes("operator.admin") === true,
|
||||||
});
|
});
|
||||||
if (!allowWrites) {
|
if (!writeAuth.allowed) {
|
||||||
const hint = `channels.${channelId}.configWrites=true`;
|
const blocked = writeAuth.blockedScope?.scope;
|
||||||
|
const hint =
|
||||||
|
blocked?.channelId && blocked.accountId
|
||||||
|
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
|
||||||
|
: `channels.${blocked?.channelId ?? channelId}.configWrites=true`;
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
|
reply: {
|
||||||
|
text: `⚠️ Config writes are disabled for ${blocked?.channelId ?? channelId}. Set ${hint} to enable.`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
|
import {
|
||||||
|
authorizeConfigWrite,
|
||||||
|
resolveConfigWriteScopesFromPath,
|
||||||
|
} from "../../channels/plugins/config-writes.js";
|
||||||
import { normalizeChannelId } from "../../channels/registry.js";
|
import { normalizeChannelId } from "../../channels/registry.js";
|
||||||
import {
|
import {
|
||||||
getConfigValueAtPath,
|
getConfigValueAtPath,
|
||||||
@@ -17,6 +20,7 @@ import {
|
|||||||
setConfigOverride,
|
setConfigOverride,
|
||||||
unsetConfigOverride,
|
unsetConfigOverride,
|
||||||
} from "../../config/runtime-overrides.js";
|
} from "../../config/runtime-overrides.js";
|
||||||
|
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||||
import {
|
import {
|
||||||
rejectUnauthorizedCommand,
|
rejectUnauthorizedCommand,
|
||||||
requireCommandFlagEnabled,
|
requireCommandFlagEnabled,
|
||||||
@@ -52,6 +56,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let parsedWritePath: string[] | undefined;
|
||||||
if (configCommand.action === "set" || configCommand.action === "unset") {
|
if (configCommand.action === "set" || configCommand.action === "unset") {
|
||||||
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
|
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
|
||||||
label: "/config write",
|
label: "/config write",
|
||||||
@@ -61,17 +66,41 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
|||||||
if (missingAdminScope) {
|
if (missingAdminScope) {
|
||||||
return missingAdminScope;
|
return missingAdminScope;
|
||||||
}
|
}
|
||||||
|
const parsedPath = parseConfigPath(configCommand.path);
|
||||||
|
if (!parsedPath.ok || !parsedPath.path) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
parsedWritePath = parsedPath.path;
|
||||||
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
|
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
|
||||||
const allowWrites = resolveChannelConfigWrites({
|
const writeAuth = authorizeConfigWrite({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channelId,
|
origin: { channelId, accountId: params.ctx.AccountId },
|
||||||
accountId: params.ctx.AccountId,
|
...resolveConfigWriteScopesFromPath(parsedWritePath),
|
||||||
|
allowBypass:
|
||||||
|
isInternalMessageChannel(params.command.channel) &&
|
||||||
|
params.ctx.GatewayClientScopes?.includes("operator.admin") === true,
|
||||||
});
|
});
|
||||||
if (!allowWrites) {
|
if (!writeAuth.allowed) {
|
||||||
const channelLabel = channelId ?? "this channel";
|
if (writeAuth.reason === "ambiguous-target") {
|
||||||
const hint = channelId
|
return {
|
||||||
? `channels.${channelId}.configWrites=true`
|
shouldContinue: false,
|
||||||
: "channels.<channel>.configWrites=true";
|
reply: {
|
||||||
|
text: "⚠️ Channel-initiated /config writes cannot replace channels, channel roots, or accounts collections. Use a more specific path or gateway operator.admin.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const blocked = writeAuth.blockedScope?.scope;
|
||||||
|
const channelLabel = blocked?.channelId ?? channelId ?? "this channel";
|
||||||
|
const hint = blocked?.channelId
|
||||||
|
? blocked?.accountId
|
||||||
|
? `channels.${blocked.channelId}.accounts.${blocked.accountId}.configWrites=true`
|
||||||
|
: `channels.${blocked.channelId}.configWrites=true`
|
||||||
|
: channelId
|
||||||
|
? `channels.${channelId}.configWrites=true`
|
||||||
|
: "channels.<channel>.configWrites=true";
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: {
|
reply: {
|
||||||
@@ -119,14 +148,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (configCommand.action === "unset") {
|
if (configCommand.action === "unset") {
|
||||||
const parsedPath = parseConfigPath(configCommand.path);
|
const removed = unsetConfigValueAtPath(parsedBase, parsedWritePath ?? []);
|
||||||
if (!parsedPath.ok || !parsedPath.path) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path);
|
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
@@ -151,14 +173,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (configCommand.action === "set") {
|
if (configCommand.action === "set") {
|
||||||
const parsedPath = parseConfigPath(configCommand.path);
|
setConfigValueAtPath(parsedBase, parsedWritePath ?? [], configCommand.value);
|
||||||
if (!parsedPath.ok || !parsedPath.path) {
|
|
||||||
return {
|
|
||||||
shouldContinue: false,
|
|
||||||
reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
|
|
||||||
const validated = validateConfigObjectWithPlugins(parsedBase);
|
const validated = validateConfigObjectWithPlugins(parsedBase);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
const issue = validated.issues[0];
|
const issue = validated.issues[0];
|
||||||
|
|||||||
@@ -682,6 +682,52 @@ describe("handleCommands /config configWrites gating", () => {
|
|||||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks /config set when the target account disables writes", async () => {
|
||||||
|
const previousWriteCount = writeConfigFileMock.mock.calls.length;
|
||||||
|
const cfg = {
|
||||||
|
commands: { config: true, text: true },
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
configWrites: true,
|
||||||
|
accounts: {
|
||||||
|
work: { configWrites: false, enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildPolicyParams(
|
||||||
|
"/config set channels.telegram.accounts.work.enabled=false",
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
AccountId: "default",
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
|
||||||
|
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks ambiguous channel-root /config writes from channel commands", async () => {
|
||||||
|
const previousWriteCount = writeConfigFileMock.mock.calls.length;
|
||||||
|
const cfg = {
|
||||||
|
commands: { config: true, text: true },
|
||||||
|
channels: { telegram: { configWrites: true } },
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildPolicyParams('/config set channels.telegram={"enabled":false}', cfg, {
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
});
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain(
|
||||||
|
"cannot replace channels, channel roots, or accounts collections",
|
||||||
|
);
|
||||||
|
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks /config set from gateway clients without operator.admin", async () => {
|
it("blocks /config set from gateway clients without operator.admin", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: { config: true, text: true },
|
commands: { config: true, text: true },
|
||||||
@@ -739,6 +785,49 @@ describe("handleCommands /config configWrites gating", () => {
|
|||||||
expect(writeConfigFileMock).toHaveBeenCalledOnce();
|
expect(writeConfigFileMock).toHaveBeenCalledOnce();
|
||||||
expect(result.reply?.text).toContain("Config updated");
|
expect(result.reply?.text).toContain("Config updated");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps /config set working for gateway operator.admin on protected account paths", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
accounts: {
|
||||||
|
work: { enabled: true, configWrites: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
const params = buildParams(
|
||||||
|
"/config set channels.telegram.accounts.work.enabled=false",
|
||||||
|
{
|
||||||
|
commands: { config: true, text: true },
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
accounts: {
|
||||||
|
work: { enabled: true, configWrites: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig,
|
||||||
|
{
|
||||||
|
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(result.reply?.text).toContain("Config updated");
|
||||||
|
const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig;
|
||||||
|
expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleCommands bash alias", () => {
|
describe("handleCommands bash alias", () => {
|
||||||
@@ -891,6 +980,31 @@ describe("handleCommands /allowlist", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks config-targeted /allowlist edits when the target account disables writes", async () => {
|
||||||
|
const previousWriteCount = writeConfigFileMock.mock.calls.length;
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
configWrites: true,
|
||||||
|
accounts: {
|
||||||
|
work: { configWrites: false, allowFrom: ["123"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
const params = buildPolicyParams("/allowlist add dm --account work --config 789", cfg, {
|
||||||
|
AccountId: "default",
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
});
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
|
||||||
|
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes default-account entries from scoped and legacy pairing stores", async () => {
|
it("removes default-account entries from scoped and legacy pairing stores", async () => {
|
||||||
removeChannelAllowFromStoreEntryMock
|
removeChannelAllowFromStoreEntryMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ function resolveAccountConfig(accounts: ChannelConfigWithAccounts["accounts"], a
|
|||||||
return resolveAccountEntry(accounts, accountId);
|
return resolveAccountEntry(accounts, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConfigWriteScope = {
|
||||||
|
channelId?: ChannelId | null;
|
||||||
|
accountId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigWriteAuthorizationResult =
|
||||||
|
| { allowed: true }
|
||||||
|
| {
|
||||||
|
allowed: false;
|
||||||
|
reason: "ambiguous-target" | "origin-disabled" | "target-disabled";
|
||||||
|
blockedScope?: { kind: "origin" | "target"; scope: ConfigWriteScope };
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveChannelConfigWrites(params: {
|
export function resolveChannelConfigWrites(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
channelId?: ChannelId | null;
|
channelId?: ChannelId | null;
|
||||||
@@ -30,3 +43,86 @@ export function resolveChannelConfigWrites(params: {
|
|||||||
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
|
const value = accountConfig?.configWrites ?? channelConfig.configWrites;
|
||||||
return value !== false;
|
return value !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function authorizeConfigWrite(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
origin?: ConfigWriteScope;
|
||||||
|
targets?: ConfigWriteScope[];
|
||||||
|
allowBypass?: boolean;
|
||||||
|
hasAmbiguousTarget?: boolean;
|
||||||
|
}): ConfigWriteAuthorizationResult {
|
||||||
|
if (params.allowBypass) {
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
if (params.hasAmbiguousTarget) {
|
||||||
|
return { allowed: false, reason: "ambiguous-target" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
params.origin?.channelId &&
|
||||||
|
!resolveChannelConfigWrites({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId: params.origin.channelId,
|
||||||
|
accountId: params.origin.accountId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "origin-disabled",
|
||||||
|
blockedScope: { kind: "origin", scope: params.origin },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const target of params.targets ?? []) {
|
||||||
|
if (!target.channelId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${target.channelId}:${normalizeAccountId(target.accountId)}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
if (
|
||||||
|
!resolveChannelConfigWrites({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId: target.channelId,
|
||||||
|
accountId: target.accountId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "target-disabled",
|
||||||
|
blockedScope: { kind: "target", scope: target },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfigWriteScopesFromPath(path: string[]): {
|
||||||
|
targets: ConfigWriteScope[];
|
||||||
|
hasAmbiguousTarget: boolean;
|
||||||
|
} {
|
||||||
|
if (path[0] !== "channels") {
|
||||||
|
return { targets: [], hasAmbiguousTarget: false };
|
||||||
|
}
|
||||||
|
if (path.length < 2) {
|
||||||
|
return { targets: [], hasAmbiguousTarget: true };
|
||||||
|
}
|
||||||
|
const channelId = path[1].trim().toLowerCase() as ChannelId;
|
||||||
|
if (!channelId) {
|
||||||
|
return { targets: [], hasAmbiguousTarget: true };
|
||||||
|
}
|
||||||
|
if (path.length === 2) {
|
||||||
|
return { targets: [{ channelId }], hasAmbiguousTarget: true };
|
||||||
|
}
|
||||||
|
if (path[2] !== "accounts") {
|
||||||
|
return { targets: [{ channelId }], hasAmbiguousTarget: false };
|
||||||
|
}
|
||||||
|
if (path.length < 4) {
|
||||||
|
return { targets: [{ channelId }], hasAmbiguousTarget: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
targets: [{ channelId, accountId: normalizeAccountId(path[3]) }],
|
||||||
|
hasAmbiguousTarget: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import {
|
|||||||
} from "../../test-utils/channel-plugins.js";
|
} from "../../test-utils/channel-plugins.js";
|
||||||
import { withEnvAsync } from "../../test-utils/env.js";
|
import { withEnvAsync } from "../../test-utils/env.js";
|
||||||
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
||||||
import { resolveChannelConfigWrites } from "./config-writes.js";
|
import {
|
||||||
|
authorizeConfigWrite,
|
||||||
|
resolveChannelConfigWrites,
|
||||||
|
resolveConfigWriteScopesFromPath,
|
||||||
|
} from "./config-writes.js";
|
||||||
import {
|
import {
|
||||||
listDiscordDirectoryGroupsFromConfig,
|
listDiscordDirectoryGroupsFromConfig,
|
||||||
listDiscordDirectoryPeersFromConfig,
|
listDiscordDirectoryPeersFromConfig,
|
||||||
@@ -325,6 +329,34 @@ describe("resolveChannelConfigWrites", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("authorizeConfigWrite", () => {
|
||||||
|
it("blocks when a target account disables writes", () => {
|
||||||
|
const cfg = makeSlackConfigWritesCfg("work");
|
||||||
|
expect(
|
||||||
|
authorizeConfigWrite({
|
||||||
|
cfg,
|
||||||
|
origin: { channelId: "slack", accountId: "default" },
|
||||||
|
targets: [{ channelId: "slack", accountId: "work" }],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
allowed: false,
|
||||||
|
reason: "target-disabled",
|
||||||
|
blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ambiguous channel collection writes", () => {
|
||||||
|
expect(resolveConfigWriteScopesFromPath(["channels", "telegram"])).toEqual({
|
||||||
|
targets: [{ channelId: "telegram" }],
|
||||||
|
hasAmbiguousTarget: true,
|
||||||
|
});
|
||||||
|
expect(resolveConfigWriteScopesFromPath(["channels", "telegram", "accounts"])).toEqual({
|
||||||
|
targets: [{ channelId: "telegram" }],
|
||||||
|
hasAmbiguousTarget: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("directory (config-backed)", () => {
|
describe("directory (config-backed)", () => {
|
||||||
it("lists Slack peers/groups from config", async () => {
|
it("lists Slack peers/groups from config", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
Reference in New Issue
Block a user