fix(gateway): fail closed on runtime config edits (#70726)

* fix(gateway): fail closed on runtime config edits

* changelog + telegram topic requireMention depth

Append a user-facing Unreleased/Fixes entry describing the fail-closed
gateway config-mutation allowlist, and extend the allowlist so Telegram
topic-level paths like
channels.telegram.groups.<group>.topics.<topic>.requireMention stay
agent-tunable instead of being rejected as protected after this change.
This commit is contained in:
Devin Robison
2026-04-23 15:23:44 -06:00
committed by GitHub
parent 02a8c13501
commit bceda6089a
4 changed files with 322 additions and 182 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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 } },
},
},
},
},
},
);
});
});

View File

@@ -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.<group>.topics.<topic>.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<string, unknown>, 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<string, unknown>)[part];
}
return current;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getValueAtPath(config: Record<string, unknown>, 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<string, unknown>,
nextConfig: Record<string, unknown>,
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<string, unknown>;
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<string, unknown>;
} => {
if (!Array.isArray(list)) {
return {
duplicateIds: false,
hasUnkeyedProtectedValue: false,
keyedValues: new Map<string, unknown>(),
};
}
let duplicateIds = false;
let hasUnkeyedProtectedValue = false;
const keyedValues = new Map<string, unknown>();
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<string, unknown>, 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<string, unknown>();
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<string>): 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<string>(),
): Set<string> {
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<string, unknown>);
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(", ")}`,
);
}