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 fba17f253aa..450c992786f 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -47,6 +47,7 @@ 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 { @@ -1188,6 +1189,20 @@ 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)) { + const sentinel_err = new Error( + `Refusing to write config for "${configPath}": found redaction sentinel ` + + `"${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( + `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); 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..7ba106d5dbe 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -3,6 +3,7 @@ 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 { REDACTED_SENTINEL } from "./redact-snapshot.js"; import type { OpenClawConfig } from "./types.js"; describe("config io write", () => { @@ -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(); + }); + }); });