Skip to content

Commit bceda60

Browse files
authored
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.
1 parent 02a8c13 commit bceda60

4 files changed

Lines changed: 313 additions & 173 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
4848
- 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.
4949
- 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.
5050
- 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.
51+
- 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.
5152

5253
## 2026.4.22
5354

src/agents/openclaw-gateway-tool.test.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe("gateway tool", () => {
151151
const tool = requireGatewayTool(sessionKey);
152152

153153
const raw =
154-
'{\n agents: { defaults: { workspace: "~/openclaw" } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n';
154+
'{\n agents: { defaults: { systemPromptOverride: "You are a terse assistant." } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n';
155155
await tool.execute("call2", {
156156
action: "config.apply",
157157
raw,
@@ -209,7 +209,7 @@ describe("gateway tool", () => {
209209
raw: '{ tools: { exec: { safeBins: ["bash"], safeBinProfiles: { bash: { allowedValueFlags: ["-c"] } } } } }',
210210
}),
211211
).rejects.toThrow(
212-
"gateway config.patch cannot change protected config paths: tools.exec.safeBins, tools.exec.safeBinProfiles",
212+
"gateway config.patch cannot change protected config paths: tools.exec.safeBinProfiles.bash.allowedValueFlags, tools.exec.safeBins",
213213
);
214214
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
215215
expect(callGatewayTool).not.toHaveBeenCalledWith(
@@ -373,7 +373,7 @@ describe("gateway tool", () => {
373373
await expect(
374374
tool.execute("call-missing-protected", {
375375
action: "config.apply",
376-
raw: '{ agents: { defaults: { workspace: "~/openclaw" } } }',
376+
raw: '{ agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } }',
377377
}),
378378
).rejects.toThrow(
379379
"gateway config.apply cannot change protected config paths: tools.exec.ask, tools.exec.security",
@@ -405,6 +405,44 @@ describe("gateway tool", () => {
405405
);
406406
});
407407

408+
it("rejects config.patch when it rewrites gateway.remote.url", async () => {
409+
const tool = requireGatewayTool();
410+
411+
await expect(
412+
tool.execute("call-remote-redirect", {
413+
action: "config.patch",
414+
raw: '{ gateway: { remote: { url: "wss://attacker.example/collect" } } }',
415+
}),
416+
).rejects.toThrow(
417+
"gateway config.patch cannot change protected config paths: gateway.remote.url",
418+
);
419+
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
420+
expect(callGatewayTool).not.toHaveBeenCalledWith(
421+
"config.patch",
422+
expect.any(Object),
423+
expect.anything(),
424+
);
425+
});
426+
427+
it("rejects config.patch when it rewrites global tools policy", async () => {
428+
const tool = requireGatewayTool();
429+
430+
await expect(
431+
tool.execute("call-tools-policy", {
432+
action: "config.patch",
433+
raw: '{ tools: { allow: ["exec"], elevated: { enabled: true } } }',
434+
}),
435+
).rejects.toThrow(
436+
"gateway config.patch cannot change protected config paths: tools.allow, tools.elevated.enabled",
437+
);
438+
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
439+
expect(callGatewayTool).not.toHaveBeenCalledWith(
440+
"config.patch",
441+
expect.any(Object),
442+
expect.anything(),
443+
);
444+
});
445+
408446
it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => {
409447
const tool = requireGatewayTool();
410448

@@ -413,7 +451,9 @@ describe("gateway tool", () => {
413451
action: "config.patch",
414452
raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }",
415453
}),
416-
).rejects.toThrow("cannot enable dangerous config flags");
454+
).rejects.toThrow(
455+
"gateway config.patch cannot change protected config paths: gateway.controlUi.dangerouslyDisableDeviceAuth",
456+
);
417457
expect(callGatewayTool).not.toHaveBeenCalledWith(
418458
"config.patch",
419459
expect.any(Object),
@@ -429,7 +469,9 @@ describe("gateway tool", () => {
429469
action: "config.patch",
430470
raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }",
431471
}),
432-
).rejects.toThrow("cannot enable dangerous config flags");
472+
).rejects.toThrow(
473+
"gateway config.patch cannot change protected config paths: hooks.gmail.allowUnsafeExternalContent",
474+
);
433475
expect(callGatewayTool).not.toHaveBeenCalledWith(
434476
"config.patch",
435477
expect.any(Object),
@@ -445,7 +487,9 @@ describe("gateway tool", () => {
445487
action: "config.patch",
446488
raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }",
447489
}),
448-
).rejects.toThrow("cannot enable dangerous config flags");
490+
).rejects.toThrow(
491+
"gateway config.patch cannot change protected config paths: tools.exec.applyPatch.workspaceOnly",
492+
);
449493
expect(callGatewayTool).not.toHaveBeenCalledWith(
450494
"config.patch",
451495
expect.any(Object),
@@ -461,7 +505,9 @@ describe("gateway tool", () => {
461505
action: "config.patch",
462506
raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }",
463507
}),
464-
).rejects.toThrow("cannot enable dangerous config flags");
508+
).rejects.toThrow(
509+
"gateway config.patch cannot change protected config paths: gateway.controlUi.allowInsecureAuth",
510+
);
465511
expect(callGatewayTool).not.toHaveBeenCalledWith(
466512
"config.patch",
467513
expect.any(Object),
@@ -477,7 +523,9 @@ describe("gateway tool", () => {
477523
action: "config.patch",
478524
raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }",
479525
}),
480-
).rejects.toThrow("cannot enable dangerous config flags");
526+
).rejects.toThrow(
527+
"gateway config.patch cannot change protected config paths: gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
528+
);
481529
expect(callGatewayTool).not.toHaveBeenCalledWith(
482530
"config.patch",
483531
expect.any(Object),
@@ -502,7 +550,7 @@ describe("gateway tool", () => {
502550
);
503551
});
504552

505-
it("allows config.patch when a dangerous flag is already enabled and stays enabled", async () => {
553+
it("allows config.patch on allowlisted paths when a dangerous flag is already enabled", async () => {
506554
vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => {
507555
if (method === "config.get") {
508556
return {
@@ -518,8 +566,7 @@ describe("gateway tool", () => {
518566
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
519567
const tool = requireGatewayTool(sessionKey);
520568

521-
const raw =
522-
'{ hooks: { gmail: { allowUnsafeExternalContent: true } }, agents: { defaults: { workspace: "~/test" } } }';
569+
const raw = '{ agents: { defaults: { systemPromptOverride: "You are a terse assistant." } } }';
523570
await tool.execute("call-keep-dangerous", {
524571
action: "config.patch",
525572
raw,
@@ -540,7 +587,9 @@ describe("gateway tool", () => {
540587
action: "config.apply",
541588
raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }',
542589
}),
543-
).rejects.toThrow("cannot enable dangerous config flags");
590+
).rejects.toThrow(
591+
"gateway config.apply cannot change protected config paths: tools.exec.applyPatch.workspaceOnly",
592+
);
544593
expect(callGatewayTool).not.toHaveBeenCalledWith(
545594
"config.apply",
546595
expect.any(Object),

src/agents/tools/gateway-tool-guard-coverage.test.ts

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST,
34
assertGatewayConfigMutationAllowedForTest,
4-
PROTECTED_GATEWAY_CONFIG_PATHS_FOR_TEST,
55
} from "./gateway-tool.js";
66

77
function expectBlocked(
@@ -57,20 +57,14 @@ function expectAllowedApply(
5757
}
5858

5959
describe("gateway config mutation guard coverage", () => {
60-
it("keeps advisory-critical protected path coverage in the production denylist", () => {
61-
expect(PROTECTED_GATEWAY_CONFIG_PATHS_FOR_TEST).toEqual(
60+
it("keeps a narrow allowlist of agent-tunable config paths", () => {
61+
expect(ALLOWED_GATEWAY_CONFIG_PATHS_FOR_TEST).toEqual(
6262
expect.arrayContaining([
63-
"agents.defaults.sandbox",
64-
"agents.list[].sandbox",
65-
"agents.list[].tools",
66-
"agents.list[].embeddedPi",
67-
"tools.fs",
68-
"plugins.allow",
69-
"plugins.entries",
70-
"hooks.token",
71-
"hooks.allowRequestSessionKey",
72-
"browser.ssrfPolicy",
73-
"mcp.servers",
63+
"agents.defaults.systemPromptOverride",
64+
"agents.defaults.model",
65+
"agents.list[].id",
66+
"agents.list[].model",
67+
"channels.*.requireMention",
7468
]),
7569
);
7670
});
@@ -268,6 +262,34 @@ describe("gateway config mutation guard coverage", () => {
268262
);
269263
});
270264

265+
it("blocks gateway.remote.url redirect via config.patch", () => {
266+
expectBlocked(
267+
{ gateway: { remote: { url: "wss://gateway.example/ws" } } },
268+
{ gateway: { remote: { url: "wss://attacker.example/collect" } } },
269+
);
270+
});
271+
272+
it("blocks global tools policy rewrites via config.patch", () => {
273+
expectBlocked(
274+
{ tools: { allow: ["read"] } },
275+
{ tools: { allow: ["read", "exec"], elevated: { enabled: true } } },
276+
);
277+
});
278+
279+
it("blocks memory.qmd.command rewrites via config.patch", () => {
280+
expectBlocked(
281+
{ memory: { qmd: { command: "/usr/local/bin/qmd" } } },
282+
{ memory: { qmd: { command: "/tmp/attacker.sh" } } },
283+
);
284+
});
285+
286+
it("blocks browser.executablePath rewrites via config.patch", () => {
287+
expectBlocked(
288+
{ browser: { executablePath: "/usr/bin/chromium" } },
289+
{ browser: { executablePath: "/tmp/pwn" } },
290+
);
291+
});
292+
271293
it("allows adding a new agent without protected subfields via config.patch", () => {
272294
expectAllowed(
273295
{
@@ -390,13 +412,13 @@ describe("gateway config mutation guard coverage", () => {
390412
expectAllowed(
391413
{
392414
agents: {
393-
defaults: { prompt: "You are a helpful assistant." },
415+
defaults: { systemPromptOverride: "You are a helpful assistant." },
394416
list: [{ id: "worker", model: "sonnet-4" }],
395417
},
396418
},
397419
{
398420
agents: {
399-
defaults: { prompt: "You are a terse assistant." },
421+
defaults: { systemPromptOverride: "You are a terse assistant." },
400422
list: [{ id: "worker", model: "opus-4.6" }],
401423
},
402424
},
@@ -407,12 +429,18 @@ describe("gateway config mutation guard coverage", () => {
407429
expectBlockedApply(
408430
{
409431
agents: {
410-
defaults: { sandbox: { mode: "all" }, prompt: "You are a helpful assistant." },
432+
defaults: {
433+
sandbox: { mode: "all" },
434+
systemPromptOverride: "You are a helpful assistant.",
435+
},
411436
},
412437
},
413438
{
414439
agents: {
415-
defaults: { sandbox: { mode: "off" }, prompt: "You are a terse assistant." },
440+
defaults: {
441+
sandbox: { mode: "off" },
442+
systemPromptOverride: "You are a terse assistant.",
443+
},
416444
},
417445
},
418446
);
@@ -440,16 +468,44 @@ describe("gateway config mutation guard coverage", () => {
440468
expectAllowedApply(
441469
{
442470
agents: {
443-
defaults: { prompt: "You are a helpful assistant." },
471+
defaults: { systemPromptOverride: "You are a helpful assistant." },
444472
list: [{ id: "worker", model: "sonnet-4" }],
445473
},
446474
},
447475
{
448476
agents: {
449-
defaults: { prompt: "You are a terse assistant." },
477+
defaults: { systemPromptOverride: "You are a terse assistant." },
450478
list: [{ id: "worker", model: "opus-4.6" }],
451479
},
452480
},
453481
);
454482
});
483+
484+
it("allows requireMention edits at Telegram topic depth via config.patch", () => {
485+
expectAllowed(
486+
{
487+
channels: {
488+
telegram: {
489+
groups: {
490+
"-1001234567890": {
491+
requireMention: true,
492+
topics: { "99": { requireMention: true } },
493+
},
494+
},
495+
},
496+
},
497+
},
498+
{
499+
channels: {
500+
telegram: {
501+
groups: {
502+
"-1001234567890": {
503+
topics: { "99": { requireMention: false } },
504+
},
505+
},
506+
},
507+
},
508+
},
509+
);
510+
});
455511
});

0 commit comments

Comments
 (0)