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.

<!-- AI-assisted (protocol-zero + Claude) -->

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Protocol-zero-0 2026-02-20 14:15:03 +00:00
parent 89e3969d64
commit 0e309cdc88
2 changed files with 49 additions and 3 deletions

View File

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

View File

@ -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<string, unknown>).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<string, unknown>;
expect(JSON.stringify(ondisk)).not.toContain(REDACTED_SENTINEL);
expect(ondisk).toEqual(initialConfig);
expect(errorFn).toHaveBeenCalled();
});
});
});