From 0e309cdc88bb998c85be82682e98e2d92a7b065e Mon Sep 17 00:00:00 2001 From: Protocol-zero-0 <257158451+Protocol-zero-0@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:15:03 +0000 Subject: [PATCH 1/4] fix: guard writeConfigFile against persisting redaction sentinels (#18102) `writeConfigFile` lacked a safety check for the `__OPENCLAW_REDACTED__` sentinel. If a redacted value reached the write path (e.g. via `doctor --fix` or any other caller), it would be serialized to disk, permanently destroying the original credential. Add a pre-write guard that scans the final serialized JSON for the sentinel string and throws before any I/O, keeping the on-disk config intact. This protects all callers of `writeConfigFile` universally. Co-authored-by: Cursor --- src/config/io.ts | 20 ++++++++++++++++--- src/config/io.write-config.test.ts | 32 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index fba17f253aa..9f7183e072e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1,10 +1,11 @@ +import JSON5 from "json5"; +import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; +import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; -import JSON5 from "json5"; -import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { @@ -47,8 +48,8 @@ import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin. import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; +import { REDACTED_SENTINEL } from "./redact-snapshot.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; -import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObjectRawWithPlugins, validateConfigObjectWithPlugins, @@ -1188,6 +1189,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // explicitly set values. Runtime defaults are applied when loading (issue #6070). const stampedOutputConfig = stampConfigVersion(outputConfig); const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n"); + + if (json.includes(REDACTED_SENTINEL)) { + deps.logger.error( + `Config write blocked: redaction sentinel "${REDACTED_SENTINEL}" found in output — ` + + `writing would destroy credentials. Config file was NOT modified.`, + ); + throw new Error( + `Refusing to write config: found redaction sentinel "${REDACTED_SENTINEL}". ` + + `This is a bug — credentials would be permanently lost. ` + + `The config file on disk was not changed.`, + ); + } + const nextHash = hashConfigRaw(json); const previousHash = resolveConfigSnapshotHash(snapshot); const changedPathCount = changedPaths?.size; diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 68709725d83..5d00d54c40e 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; import type { OpenClawConfig } from "./types.js"; +import { REDACTED_SENTINEL } from "./redact-snapshot.js"; describe("config io write", () => { let fixtureRoot = ""; @@ -548,4 +549,35 @@ describe("config io write", () => { expect(last.watchCommand).toBe("gateway --force"); }); }); + + it("refuses to write config containing redaction sentinel (issue #18102)", async () => { + await withSuiteHome(async (home) => { + const initialConfig = { + gateway: { port: 18789 }, + channels: { telegram: { botToken: "real-bot-token-123" } }, + }; + const errorFn = vi.fn(); + const { configPath, io, snapshot } = await writeConfigAndCreateIo({ + home, + initialConfig, + logger: { warn: vi.fn(), error: errorFn }, + }); + + const poisoned = structuredClone(snapshot.config); + (poisoned as Record).channels = { + ...poisoned.channels, + telegram: { + ...poisoned.channels?.telegram, + botToken: REDACTED_SENTINEL, + }, + }; + + await expect(io.writeConfigFile(poisoned)).rejects.toThrow(/redaction sentinel/i); + + const ondisk = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + expect(JSON.stringify(ondisk)).not.toContain(REDACTED_SENTINEL); + expect(ondisk).toEqual(initialConfig); + expect(errorFn).toHaveBeenCalled(); + }); + }); }); From 98ebdceb64b8f4da773808a6bea9abe240a127a9 Mon Sep 17 00:00:00 2001 From: Protocol-zero-0 <257158451+Protocol-zero-0@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:29:07 +0000 Subject: [PATCH 2/4] fix: include configPath and stack trace in sentinel guard diagnostics Address review feedback from @HenryLoenwind and Copilot: - Include configPath in both the logger.error and thrown Error so overridden config paths are visible in diagnostics. - Log the full stack trace so the call site is preserved even if upstream code catches and re-wraps the error. Co-authored-by: Cursor --- src/config/io.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index 9f7183e072e..c5ccbb2f33b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -50,6 +50,7 @@ import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } fr import { isBlockedObjectKey } from "./prototype-keys.js"; import { REDACTED_SENTINEL } from "./redact-snapshot.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; +import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObjectRawWithPlugins, validateConfigObjectWithPlugins, @@ -1191,15 +1192,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n"); if (json.includes(REDACTED_SENTINEL)) { - deps.logger.error( - `Config write blocked: redaction sentinel "${REDACTED_SENTINEL}" found in output — ` + - `writing would destroy credentials. Config file was NOT modified.`, - ); - throw new Error( - `Refusing to write config: found redaction sentinel "${REDACTED_SENTINEL}". ` + - `This is a bug — credentials would be permanently lost. ` + + const sentinel_err = new Error( + `Refusing to write config for "${configPath}": found redaction sentinel ` + + `"${REDACTED_SENTINEL}". This is a bug — credentials would be permanently lost. ` + `The config file on disk was not changed.`, ); + deps.logger.error( + `Config write blocked for "${configPath}": redaction sentinel "${REDACTED_SENTINEL}" ` + + `found in output — writing would destroy credentials. Config file was NOT modified.\n${sentinel_err.stack}`, + ); + throw sentinel_err; } const nextHash = hashConfigRaw(json); From df4b903b1f8b7e9a6178e65cee39ac025afa8a87 Mon Sep 17 00:00:00 2001 From: Protocol-zero-0 <257158451+Protocol-zero-0@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:39:38 +0000 Subject: [PATCH 3/4] fix: clarify bug responsibility in sentinel guard message Address review feedback from @HenryLoenwind: - Change "This is a bug" to "This is a bug in the calling code shown in the attached stacktrace" to prevent users from reporting against the guard itself instead of providing the calling trace. Co-authored-by: Cursor --- src/config/io.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/io.ts b/src/config/io.ts index c5ccbb2f33b..7d19893a813 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1194,7 +1194,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (json.includes(REDACTED_SENTINEL)) { const sentinel_err = new Error( `Refusing to write config for "${configPath}": found redaction sentinel ` + - `"${REDACTED_SENTINEL}". This is a bug — credentials would be permanently lost. ` + + `"${REDACTED_SENTINEL}". This is a bug in the calling code shown in the attached stacktrace — credentials would be permanently lost. ` + `The config file on disk was not changed.`, ); deps.logger.error( From 546491bbe13e262dadbefb848aabeb1d8018a69a Mon Sep 17 00:00:00 2001 From: Protocol-zero-0 <257158451+Protocol-zero-0@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:22:15 +0000 Subject: [PATCH 4/4] fix: align sentinel guard branch with CI checks Refresh the detect-secrets baseline and normalize the config io import ordering so the sentinel guard PR passes the current formatting and build checks after rebase. Made-with: Cursor --- .secrets.baseline | 2 +- src/config/io.ts | 5 ++--- src/config/io.write-config.test.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 07641fb920b..a51216f90af 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -12196,7 +12196,7 @@ "filename": "src/config/io.write-config.test.ts", "hashed_secret": "13951588fd3325e25ed1e3b116d7009fb221c85e", "is_verified": false, - "line_number": 289 + "line_number": 290 } ], "src/config/model-alias-defaults.test.ts": [ diff --git a/src/config/io.ts b/src/config/io.ts index 7d19893a813..450c992786f 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1,11 +1,10 @@ -import JSON5 from "json5"; -import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; -import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import JSON5 from "json5"; +import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 5d00d54c40e..7ba106d5dbe 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -3,8 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; -import type { OpenClawConfig } from "./types.js"; import { REDACTED_SENTINEL } from "./redact-snapshot.js"; +import type { OpenClawConfig } from "./types.js"; describe("config io write", () => { let fixtureRoot = "";