diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b017fe7100..0462723781c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Memory/doctor: keep root durable memory canonicalized on `MEMORY.md`, stop treating lowercase `memory.md` as a runtime fallback, and let `openclaw doctor --fix` merge true split-brain root files into `MEMORY.md` with a backup. (#70621) Thanks @mbelinky. - Providers/Anthropic Vertex: restore ADC-backed model discovery after the lightweight provider-discovery path by resolving emitted discovery entries, exposing synthetic auth on bootstrap discovery, and honoring copied env snapshots when probing the default GCP ADC path. Fixes #65715. (#65716) Thanks @feiskyer. - Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions. +- Gateway/security: fail closed on agent-driven `gateway config.apply`/`config.patch` runtime edits by allowlisting a narrow set of agent-tunable prompt, model, and mention-gating paths (including Telegram topic-level `requireMention`) instead of relying on a hand-maintained denylist of protected subtrees that could miss new sensitive config keys. (#70726) Thanks @drobison00. ## 2026.4.22 diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index f83d80a783b..eefb0a78403 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -151,7 +151,7 @@ describe("gateway tool", () => { const tool = requireGatewayTool(sessionKey); const raw = - '{\n agents: { defaults: { workspace: "~/openclaw" } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n'; + '{\n agents: { defaults: { systemPromptOverride: "You are a terse assistant." } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n'; await tool.execute("call2", { action: "config.apply", raw, @@ -209,7 +209,7 @@ describe("gateway tool", () => { raw: '{ tools: { exec: { safeBins: ["bash"], safeBinProfiles: { bash: { allowedValueFlags: ["-c"] } } } } }', }), ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.exec.safeBins, tools.exec.safeBinProfiles", + "gateway config.patch cannot change protected config paths: tools.exec.safeBinProfiles.bash.allowedValueFlags, tools.exec.safeBins", ); expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {}); expect(callGatewayTool).not.toHaveBeenCalledWith( @@ -373,7 +373,7 @@ describe("gateway tool", () => { await expect( tool.execute("call-missing-protected", { action: "config.apply", - raw: '{ agents: { defaults: { workspace: "~/openclaw" } } }', + raw: '{ agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } }', }), ).rejects.toThrow( "gateway config.apply cannot change protected config paths: tools.exec.ask, tools.exec.security", @@ -405,6 +405,44 @@ describe("gateway tool", () => { ); }); + it("rejects config.patch when it rewrites gateway.remote.url", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-remote-redirect", { + action: "config.patch", + raw: '{ gateway: { remote: { url: "wss://attacker.example/collect" } } }', + }), + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: gateway.remote.url", + ); + expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {}); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + + it("rejects config.patch when it rewrites global tools policy", async () => { + const tool = requireGatewayTool(); + + await expect( + tool.execute("call-tools-policy", { + action: "config.patch", + raw: '{ tools: { allow: ["exec"], elevated: { enabled: true } } }', + }), + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: tools.allow, tools.elevated.enabled", + ); + expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {}); + expect(callGatewayTool).not.toHaveBeenCalledWith( + "config.patch", + expect.any(Object), + expect.anything(), + ); + }); + it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => { const tool = requireGatewayTool(); @@ -413,7 +451,9 @@ describe("gateway tool", () => { action: "config.patch", raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }", }), - ).rejects.toThrow("cannot enable dangerous config flags"); + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: gateway.controlUi.dangerouslyDisableDeviceAuth", + ); expect(callGatewayTool).not.toHaveBeenCalledWith( "config.patch", expect.any(Object), @@ -429,7 +469,9 @@ describe("gateway tool", () => { action: "config.patch", raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }", }), - ).rejects.toThrow("cannot enable dangerous config flags"); + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: hooks.gmail.allowUnsafeExternalContent", + ); expect(callGatewayTool).not.toHaveBeenCalledWith( "config.patch", expect.any(Object), @@ -445,7 +487,9 @@ describe("gateway tool", () => { action: "config.patch", raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }", }), - ).rejects.toThrow("cannot enable dangerous config flags"); + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: tools.exec.applyPatch.workspaceOnly", + ); expect(callGatewayTool).not.toHaveBeenCalledWith( "config.patch", expect.any(Object), @@ -461,7 +505,9 @@ describe("gateway tool", () => { action: "config.patch", raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }", }), - ).rejects.toThrow("cannot enable dangerous config flags"); + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: gateway.controlUi.allowInsecureAuth", + ); expect(callGatewayTool).not.toHaveBeenCalledWith( "config.patch", expect.any(Object), @@ -477,7 +523,9 @@ describe("gateway tool", () => { action: "config.patch", raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }", }), - ).rejects.toThrow("cannot enable dangerous config flags"); + ).rejects.toThrow( + "gateway config.patch cannot change protected config paths: gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback", + ); expect(callGatewayTool).not.toHaveBeenCalledWith( "config.patch", expect.any(Object), @@ -502,7 +550,7 @@ describe("gateway tool", () => { ); }); - it("allows config.patch when a dangerous flag is already enabled and stays enabled", async () => { + it("allows config.patch on allowlisted paths when a dangerous flag is already enabled", async () => { vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { if (method === "config.get") { return { @@ -518,8 +566,7 @@ describe("gateway tool", () => { const sessionKey = "agent:main:whatsapp:dm:+15555550123"; const tool = requireGatewayTool(sessionKey); - const raw = - '{ hooks: { gmail: { allowUnsafeExternalContent: true } }, agents: { defaults: { workspace: "~/test" } } }'; + const raw = '{ agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } }'; await tool.execute("call-keep-dangerous", { action: "config.patch", raw, @@ -540,7 +587,9 @@ describe("gateway tool", () => { action: "config.apply", raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }', }), - ).rejects.toThrow("cannot enable dangerous config flags"); + ).rejects.toThrow( + "gateway config.apply cannot change protected config paths: tools.exec.applyPatch.workspaceOnly", + ); expect(callGatewayTool).not.toHaveBeenCalledWith( "config.apply", expect.any(Object), diff --git a/src/agents/tools/gateway-tool-guard-coverage.test.ts b/src/agents/tools/gateway-tool-guard-coverage.test.ts index cdad1544332..1d8163232d6 100644 --- a/src/agents/tools/gateway-tool-guard-coverage.test.ts +++ b/src/agents/tools/gateway-tool-guard-coverage.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { + ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST, assertGatewayConfigMutationAllowedForTest, - PROTECTED_GATEWAY_CONFIG_PATHS_FOR_TEST, } from "./gateway-tool.js"; function expectBlocked( @@ -57,20 +57,14 @@ function expectAllowedApply( } describe("gateway config mutation guard coverage", () => { - it("keeps advisory-critical protected path coverage in the production denylist", () => { - expect(PROTECTED_GATEWAY_CONFIG_PATHS_FOR_TEST).toEqual( + it("keeps a narrow allowlist of agent-tunable config paths", () => { + expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toEqual( expect.arrayContaining([ - "agents.defaults.sandbox", - "agents.list[].sandbox", - "agents.list[].tools", - "agents.list[].embeddedPi", - "tools.fs", - "plugins.allow", - "plugins.entries", - "hooks.token", - "hooks.allowRequestSessionKey", - "browser.ssrfPolicy", - "mcp.servers", + "agents.defaults.systemPromptOverride", + "agents.defaults.model", + "agents.list[].id", + "agents.list[].model", + "channels.*.requireMention", ]), ); }); @@ -268,6 +262,34 @@ describe("gateway config mutation guard coverage", () => { ); }); + it("blocks gateway.remote.url redirect via config.patch", () => { + expectBlocked( + { gateway: { remote: { url: "wss://gateway.example/ws" } } }, + { gateway: { remote: { url: "wss://attacker.example/collect" } } }, + ); + }); + + it("blocks global tools policy rewrites via config.patch", () => { + expectBlocked( + { tools: { allow: ["read"] } }, + { tools: { allow: ["read", "exec"], elevated: { enabled: true } } }, + ); + }); + + it("blocks memory.qmd.command rewrites via config.patch", () => { + expectBlocked( + { memory: { qmd: { command: "/usr/local/bin/qmd" } } }, + { memory: { qmd: { command: "/tmp/attacker.sh" } } }, + ); + }); + + it("blocks browser.executablePath rewrites via config.patch", () => { + expectBlocked( + { browser: { executablePath: "/usr/bin/chromium" } }, + { browser: { executablePath: "/tmp/pwn" } }, + ); + }); + it("allows adding a new agent without protected subfields via config.patch", () => { expectAllowed( { @@ -390,13 +412,13 @@ describe("gateway config mutation guard coverage", () => { expectAllowed( { agents: { - defaults: { prompt: "You are a helpful assistant." }, + defaults: { systemPromptOverride: "You are a helpful assistant." }, list: [{ id: "worker", model: "sonnet-4" }], }, }, { agents: { - defaults: { prompt: "You are a terse assistant." }, + defaults: { systemPromptOverride: "You are a terse assistant." }, list: [{ id: "worker", model: "opus-4.6" }], }, }, @@ -407,12 +429,18 @@ describe("gateway config mutation guard coverage", () => { expectBlockedApply( { agents: { - defaults: { sandbox: { mode: "all" }, prompt: "You are a helpful assistant." }, + defaults: { + sandbox: { mode: "all" }, + systemPromptOverride: "You are a helpful assistant.", + }, }, }, { agents: { - defaults: { sandbox: { mode: "off" }, prompt: "You are a terse assistant." }, + defaults: { + sandbox: { mode: "off" }, + systemPromptOverride: "You are a terse assistant.", + }, }, }, ); @@ -440,16 +468,44 @@ describe("gateway config mutation guard coverage", () => { expectAllowedApply( { agents: { - defaults: { prompt: "You are a helpful assistant." }, + defaults: { systemPromptOverride: "You are a helpful assistant." }, list: [{ id: "worker", model: "sonnet-4" }], }, }, { agents: { - defaults: { prompt: "You are a terse assistant." }, + defaults: { systemPromptOverride: "You are a terse assistant." }, list: [{ id: "worker", model: "opus-4.6" }], }, }, ); }); + + it("allows requireMention edits at Telegram topic depth via config.patch", () => { + expectAllowed( + { + channels: { + telegram: { + groups: { + "-1001234567890": { + requireMention: true, + topics: { "99": { requireMention: true } }, + }, + }, + }, + }, + }, + { + channels: { + telegram: { + groups: { + "-1001234567890": { + topics: { "99": { requireMention: false } }, + }, + }, + }, + }, + }, + ); + }); }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 9f3ec3db6c1..f029e485f77 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -26,57 +26,35 @@ const log = createSubsystemLogger("gateway-tool"); const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000; // Security: the agent-facing `gateway` tool is owner-only, but per SECURITY.md the model/agent // itself is not a trusted principal. `assertGatewayConfigMutationAllowed` is the explicit -// model -> operator trust-boundary control on `config.apply`/`config.patch`. Any operator-trusted -// path listed here must not be changed by agent-driven mutations, including descendant keys -// reached via deep merge or `mergeObjectArraysById` in-place edits. -const PROTECTED_GATEWAY_CONFIG_PATHS = [ - // Exec consent / allowlist. - "tools.exec.ask", - "tools.exec.security", - "tools.exec.safeBins", - "tools.exec.safeBinProfiles", - "tools.exec.safeBinTrustedDirs", - "tools.exec.strictInlineEval", - // Filesystem boundary. - "tools.fs", - // Sandbox isolation and per-agent sandbox overrides. - "agents.defaults.sandbox", - "agents.sandbox", - "sandbox", - "agents.list[].sandbox", - // Per-agent tool/runtime execution policy. - "agents.list[].tools", - "agents.list[].embeddedPi", - "tools.subagents", - // Plugin trust boundary. - "plugins.enabled", - "plugins.allow", - "plugins.deny", - "plugins.entries", - "plugins.installs", - "plugins.load", - "plugins.slots", - // Gateway auth / TLS / HTTP tool exposure. - "gateway.auth", - "gateway.tls", - "gateway.tools.allow", - "gateway.tools.deny", - // Hook auth/routing and extra trusted code loading. - "hooks.token", - "hooks.allowRequestSessionKey", - "hooks.defaultSessionKey", - "hooks.allowedSessionKeyPrefixes", - "hooks.internal.load.extraDirs", - "hooks.transformsDir", - "hooks.mappings", - // SSRF and MCP transport reach. - "browser.ssrfPolicy", - "tools.web.fetch.ssrfPolicy", - "mcp.servers", +// model -> operator trust-boundary control on `config.apply`/`config.patch`, so the runtime +// tool must fail closed and allow only a narrow set of agent-tunable paths. +const ALLOWED_GATEWAY_CONFIG_PATHS = [ + // Agent prompt/model tuning. + "agents.defaults.systemPromptOverride", + "agents.defaults.promptOverlays", + "agents.defaults.model", + "agents.defaults.thinkingDefault", + "agents.defaults.reasoningDefault", + "agents.defaults.fastModeDefault", + "agents.list[].id", + "agents.list[].systemPromptOverride", + "agents.list[].model", + "agents.list[].thinkingDefault", + "agents.list[].reasoningDefault", + "agents.list[].fastModeDefault", + // Mention gating is an agent-facing scope knob across channel adapters. + // Depths here must cover the deepest `requireMention` path the channel + // adapters use today — Telegram topic overrides live at + // `channels.telegram.groups..topics..requireMention`. + "channels.*.requireMention", + "channels.*.*.requireMention", + "channels.*.*.*.requireMention", + "channels.*.*.*.*.requireMention", + "channels.*.*.*.*.*.requireMention", ] as const; /** @internal Exposed for regression tests only; do not import from runtime code. */ -export const PROTECTED_GATEWAY_CONFIG_PATHS_FOR_TEST = PROTECTED_GATEWAY_CONFIG_PATHS; +export const ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST = ALLOWED_GATEWAY_CONFIG_PATHS; /** @internal Exposed for regression tests only; do not import from runtime code. */ export function assertGatewayConfigMutationAllowedForTest(params: { @@ -129,114 +107,171 @@ function parseGatewayConfigMutationRaw( return parsedRes.parsed; } -function getValueAtCanonicalPath(config: Record, path: string): unknown { - let current: unknown = config; - for (const part of path.split(".")) { - if (!current || typeof current !== "object" || Array.isArray(current)) { - return undefined; - } - current = (current as Record)[part]; - } - return current; +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -function getValueAtPath(config: Record, path: string): unknown { - const direct = getValueAtCanonicalPath(config, path); - if (direct !== undefined) { - return direct; - } - if (!path.startsWith("tools.exec.")) { - return undefined; - } - return getValueAtCanonicalPath(config, path.replace(/^tools\.exec\./, "tools.bash.")); +function normalizeGatewayConfigPath(path: string): string { + return path.startsWith("tools.bash.") ? path.replace(/^tools\.bash\./, "tools.exec.") : path; } -function isProtectedPathEqual( - currentConfig: Record, - nextConfig: Record, - path: string, -): boolean { - const bracketIdx = path.indexOf("[]"); - if (bracketIdx === -1) { - return isDeepStrictEqual(getValueAtPath(currentConfig, path), getValueAtPath(nextConfig, path)); +function readKeyedArrayEntries(list: unknown): { + duplicateIds: boolean; + entries: Map; + hasUnkeyedEntries: boolean; +} | null { + if (!Array.isArray(list)) { + return null; } - const arrayPath = path.slice(0, bracketIdx); - const subPath = path.slice(bracketIdx + "[]".length).replace(/^\./, ""); - const currentList = getValueAtCanonicalPath(currentConfig, arrayPath); - const nextList = getValueAtCanonicalPath(nextConfig, arrayPath); - if (!Array.isArray(currentList) && !Array.isArray(nextList)) { - return true; - } - - const readProjectedEntries = ( - list: unknown, - ): { - duplicateIds: boolean; - hasUnkeyedProtectedValue: boolean; - keyedValues: Map; - } => { - if (!Array.isArray(list)) { - return { - duplicateIds: false, - hasUnkeyedProtectedValue: false, - keyedValues: new Map(), - }; - } - let duplicateIds = false; - let hasUnkeyedProtectedValue = false; - const keyedValues = new Map(); - for (const entry of list) { - const id = - entry && - typeof entry === "object" && - !Array.isArray(entry) && - typeof (entry as { id?: unknown }).id === "string" && - (entry as { id: string }).id.length > 0 - ? (entry as { id: string }).id - : undefined; - const value = - !subPath || !entry || typeof entry !== "object" || Array.isArray(entry) - ? entry - : getValueAtCanonicalPath(entry as Record, subPath); - if (!id) { - hasUnkeyedProtectedValue ||= value !== undefined; - continue; - } - if (keyedValues.has(id)) { - duplicateIds = true; - continue; - } - keyedValues.set(id, value); - } - return { duplicateIds, hasUnkeyedProtectedValue, keyedValues }; - }; - - const currentProjected = readProjectedEntries(currentList); - const nextProjected = readProjectedEntries(nextList); - if (nextProjected.duplicateIds || nextProjected.hasUnkeyedProtectedValue) { - return false; - } - for (const [id, currentValue] of currentProjected.keyedValues) { - if (!nextProjected.keyedValues.has(id)) { - // Dropping an entry that currently carries an operator-set protected - // subfield value strips that operator state — treat as a protected - // change so per-agent overrides cannot be removed via config.apply. - if (currentValue !== undefined) { - return false; - } + let duplicateIds = false; + let hasUnkeyedEntries = false; + const entries = new Map(); + for (const entry of list) { + if (!isPlainObject(entry) || typeof entry.id !== "string" || entry.id.length === 0) { + hasUnkeyedEntries = true; continue; } - if (!isDeepStrictEqual(currentValue, nextProjected.keyedValues.get(id))) { + if (entries.has(entry.id)) { + duplicateIds = true; + continue; + } + entries.set(entry.id, entry); + } + return { duplicateIds, entries, hasUnkeyedEntries }; +} + +function collectConfigLeafPaths(value: unknown, basePath: string, out: Set): void { + const canonicalPath = normalizeGatewayConfigPath(basePath); + if (value === undefined) { + if (canonicalPath) { + out.add(canonicalPath); + } + return; + } + + if (Array.isArray(value)) { + const keyedEntries = readKeyedArrayEntries(value); + if ( + keyedEntries && + !keyedEntries.duplicateIds && + !keyedEntries.hasUnkeyedEntries && + keyedEntries.entries.size > 0 + ) { + for (const entryValue of keyedEntries.entries.values()) { + collectConfigLeafPaths(entryValue, `${basePath}[]`, out); + } + return; + } + if (canonicalPath) { + out.add(canonicalPath); + } + return; + } + + if (!isPlainObject(value)) { + if (canonicalPath) { + out.add(canonicalPath); + } + return; + } + + const entries = Object.entries(value); + if (entries.length === 0) { + if (canonicalPath) { + out.add(canonicalPath); + } + return; + } + + for (const [key, child] of entries) { + collectConfigLeafPaths(child, basePath ? `${basePath}.${key}` : key, out); + } +} + +function collectChangedConfigPaths( + currentValue: unknown, + nextValue: unknown, + basePath = "", + out = new Set(), +): Set { + if (isDeepStrictEqual(currentValue, nextValue)) { + return out; + } + + if (currentValue === undefined || nextValue === undefined) { + collectConfigLeafPaths(currentValue ?? nextValue, basePath, out); + return out; + } + + if (Array.isArray(currentValue) || Array.isArray(nextValue)) { + if (!Array.isArray(currentValue) || !Array.isArray(nextValue)) { + collectConfigLeafPaths(currentValue, basePath, out); + collectConfigLeafPaths(nextValue, basePath, out); + return out; + } + + const currentEntries = readKeyedArrayEntries(currentValue); + const nextEntries = readKeyedArrayEntries(nextValue); + if ( + !currentEntries || + !nextEntries || + currentEntries.duplicateIds || + nextEntries.duplicateIds || + currentEntries.hasUnkeyedEntries || + nextEntries.hasUnkeyedEntries + ) { + out.add(normalizeGatewayConfigPath(basePath)); + return out; + } + + const ids = new Set([...currentEntries.entries.keys(), ...nextEntries.entries.keys()]); + for (const id of ids) { + collectChangedConfigPaths( + currentEntries.entries.get(id), + nextEntries.entries.get(id), + `${basePath}[]`, + out, + ); + } + return out; + } + + if (isPlainObject(currentValue) && isPlainObject(nextValue)) { + const keys = new Set([...Object.keys(currentValue), ...Object.keys(nextValue)]); + for (const key of keys) { + collectChangedConfigPaths( + currentValue[key], + nextValue[key], + basePath ? `${basePath}.${key}` : key, + out, + ); + } + return out; + } + + out.add(normalizeGatewayConfigPath(basePath)); + return out; +} + +function pathSegmentMatches(patternSegment: string, pathSegment: string): boolean { + return patternSegment === "*" || patternSegment === pathSegment; +} + +function isAllowedGatewayConfigPath(path: string): boolean { + const pathSegments = path.split("."); + return ALLOWED_GATEWAY_CONFIG_PATHS.some((pattern) => { + const patternSegments = pattern.split("."); + if (patternSegments.length > pathSegments.length) { return false; } - } - for (const [id, nextValue] of nextProjected.keyedValues) { - if (!currentProjected.keyedValues.has(id) && nextValue !== undefined) { - return false; + for (let i = 0; i < patternSegments.length; i += 1) { + if (!pathSegmentMatches(patternSegments[i], pathSegments[i])) { + return false; + } } - } - return true; + return true; + }); } function assertGatewayConfigMutationAllowed(params: { @@ -251,12 +286,11 @@ function assertGatewayConfigMutationAllowed(params: { : (applyMergePatch(params.currentConfig, parsed, { mergeObjectArraysById: true, }) as Record); - const changedProtectedPaths = PROTECTED_GATEWAY_CONFIG_PATHS.filter( - (path) => !isProtectedPathEqual(params.currentConfig, nextConfig, path), - ); - if (changedProtectedPaths.length > 0) { + const changedPaths = [...collectChangedConfigPaths(params.currentConfig, nextConfig)].toSorted(); + const disallowedPaths = changedPaths.filter((path) => !isAllowedGatewayConfigPath(path)); + if (disallowedPaths.length > 0) { throw new Error( - `gateway ${params.action} cannot change protected config paths: ${changedProtectedPaths.join(", ")}`, + `gateway ${params.action} cannot change protected config paths: ${disallowedPaths.join(", ")}`, ); }