mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user