fix: audit clobbered config reads

This commit is contained in:
Peter Steinberger
2026-03-24 17:10:04 +00:00
parent 14f1b65c70
commit f52752889b
6 changed files with 317 additions and 19 deletions

View File

@@ -1,6 +1,4 @@
import fs from "node:fs";
import JSON5 from "json5";
import { resolveConfigPath } from "../config/paths.js";
import { createConfigIO } from "../config/config.js";
import type { TaglineMode } from "./tagline.js";
function parseTaglineMode(value: unknown): TaglineMode | undefined {
@@ -14,9 +12,9 @@ export function readCliBannerTaglineMode(
env: NodeJS.ProcessEnv = process.env,
): TaglineMode | undefined {
try {
const configPath = resolveConfigPath(env);
const raw = fs.readFileSync(configPath, "utf8");
const parsed: { cli?: { banner?: { taglineMode?: unknown } } } = JSON5.parse(raw);
const parsed = createConfigIO({ env }).loadConfig() as {
cli?: { banner?: { taglineMode?: unknown } };
};
return parseTaglineMode(parsed.cli?.banner?.taglineMode);
} catch {
return undefined;

View File

@@ -193,6 +193,31 @@ describe("gateway run option collisions", () => {
);
});
it("blocks startup when the observed snapshot loses gateway.mode even if loadConfig still says local", async () => {
configState.cfg = {
gateway: {
mode: "local",
},
};
configState.snapshot = {
exists: true,
valid: true,
config: {
update: { channel: "beta" },
},
parsed: {
update: { channel: "beta" },
},
};
await expect(runGatewayCli(["gateway", "run"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors).toContain(
"Gateway start blocked: set gateway.mode=local (current: unset) or pass --allow-unconfigured.",
);
expect(startGatewayServer).not.toHaveBeenCalled();
});
it.each(["none", "trusted-proxy"] as const)("accepts --auth %s override", async (mode) => {
await runGatewayCli(["gateway", "run", "--auth", mode, "--allow-unconfigured"]);

View File

@@ -317,7 +317,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const snapshot = await readConfigFileSnapshot().catch(() => null);
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl");
const mode = cfg.gateway?.mode;
const effectiveCfg = snapshot?.valid ? snapshot.config : cfg;
const mode = effectiveCfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) {
defaultRuntime.error(

View File

@@ -125,4 +125,46 @@ describe("config io observe", () => {
expect(observeEvents).toHaveLength(1);
});
});
it("records forensic audit from loadConfig when only the backup file provides the baseline", async () => {
await withSuiteHome(async (home) => {
const { io, configPath, auditPath, warn } = await makeIo(home);
await io.writeConfigFile({
update: { channel: "beta" },
gateway: { mode: "local" },
channels: {
telegram: {
enabled: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
},
},
});
await fs.copyFile(configPath, `${configPath}.bak`);
const clobberedRaw = `${JSON.stringify({ update: { channel: "beta" } }, null, 2)}\n`;
await fs.writeFile(configPath, clobberedRaw, "utf-8");
const loaded = io.loadConfig();
expect(loaded.gateway?.mode).toBeUndefined();
const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean);
const observe = lines
.map((line) => JSON.parse(line) as Record<string, unknown>)
.filter((line) => line.event === "config.observe")
.at(-1);
expect(observe).toBeDefined();
expect(observe?.backupHash).toBeTypeOf("string");
expect(observe?.suspicious).toEqual(
expect.arrayContaining(["gateway-mode-missing-vs-last-good", "update-channel-only-root"]),
);
const anomalyLog = warn.mock.calls
.map((call) => call[0])
.find((entry) => typeof entry === "string" && entry.startsWith("Config observe anomaly:"));
expect(anomalyLog).toContain(configPath);
});
});
});

View File

@@ -640,6 +640,22 @@ async function appendConfigAuditRecord(
}
}
function appendConfigAuditRecordSync(
deps: Required<ConfigIoDeps>,
record: ConfigAuditRecord,
): void {
try {
const auditPath = resolveConfigAuditLogPath(deps.env, deps.homedir);
deps.fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
deps.fs.appendFileSync(auditPath, `${JSON.stringify(record)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}
async function readConfigHealthState(deps: Required<ConfigIoDeps>): Promise<ConfigHealthState> {
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
@@ -651,6 +667,17 @@ async function readConfigHealthState(deps: Required<ConfigIoDeps>): Promise<Conf
}
}
function readConfigHealthStateSync(deps: Required<ConfigIoDeps>): ConfigHealthState {
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
const raw = deps.fs.readFileSync(healthPath, "utf-8");
const parsed = JSON.parse(raw);
return isPlainObject(parsed) ? (parsed as ConfigHealthState) : {};
} catch {
return {};
}
}
async function writeConfigHealthState(
deps: Required<ConfigIoDeps>,
state: ConfigHealthState,
@@ -667,6 +694,19 @@ async function writeConfigHealthState(
}
}
function writeConfigHealthStateSync(deps: Required<ConfigIoDeps>, state: ConfigHealthState): void {
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}
function getConfigHealthEntry(state: ConfigHealthState, configPath: string): ConfigHealthEntry {
const entries = state.entries;
if (!entries || !isPlainObject(entries)) {
@@ -756,6 +796,29 @@ async function readConfigFingerprintForPath(
}
}
function readConfigFingerprintForPathSync(
deps: Required<ConfigIoDeps>,
targetPath: string,
): ConfigHealthFingerprint | null {
try {
const raw = deps.fs.readFileSync(targetPath, "utf-8");
const stat = deps.fs.statSync(targetPath, { throwIfNoEntry: false }) ?? null;
const parsedRes = parseConfigJson5(raw, deps.json5);
const parsed = parsedRes.ok ? parsedRes.parsed : {};
return {
hash: hashConfigRaw(raw),
bytes: Buffer.byteLength(raw, "utf-8"),
mtimeMs: stat?.mtimeMs ?? null,
ctimeMs: stat?.ctimeMs ?? null,
hasMeta: hasConfigMeta(parsed),
gatewayMode: resolveGatewayMode(parsed),
observedAt: new Date().toISOString(),
};
} catch {
return null;
}
}
function formatConfigArtifactTimestamp(ts: string): string {
return ts.replaceAll(":", "-").replaceAll(".", "-");
}
@@ -779,6 +842,25 @@ async function persistClobberedConfigSnapshot(params: {
}
}
function persistClobberedConfigSnapshotSync(params: {
deps: Required<ConfigIoDeps>;
configPath: string;
raw: string;
observedAt: string;
}): string | null {
const targetPath = `${params.configPath}.clobbered.${formatConfigArtifactTimestamp(params.observedAt)}`;
try {
params.deps.fs.writeFileSync(targetPath, params.raw, {
encoding: "utf-8",
mode: 0o600,
flag: "wx",
});
return targetPath;
} catch {
return null;
}
}
function sameFingerprint(
left: ConfigHealthFingerprint | undefined,
right: ConfigHealthFingerprint,
@@ -818,12 +900,16 @@ async function observeConfigSnapshot(
let healthState = await readConfigHealthState(deps);
const entry = getConfigHealthEntry(healthState, snapshot.path);
const backupBaseline =
entry.lastKnownGood ??
(await readConfigFingerprintForPath(deps, `${snapshot.path}.bak`)) ??
undefined;
const suspicious = resolveConfigObserveSuspiciousReasons({
bytes: current.bytes,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
parsed: snapshot.parsed,
lastKnownGood: entry.lastKnownGood,
lastKnownGood: backupBaseline,
});
if (suspicious.length === 0) {
@@ -848,7 +934,9 @@ async function observeConfigSnapshot(
return;
}
const backup = await readConfigFingerprintForPath(deps, `${snapshot.path}.bak`);
const backup =
(backupBaseline?.hash ? backupBaseline : null) ??
(await readConfigFingerprintForPath(deps, `${snapshot.path}.bak`));
const clobberedPath = await persistClobberedConfigSnapshot({
deps,
configPath: snapshot.path,
@@ -897,6 +985,113 @@ async function observeConfigSnapshot(
await writeConfigHealthState(deps, healthState);
}
function observeConfigSnapshotSync(
deps: Required<ConfigIoDeps>,
snapshot: ConfigFileSnapshot,
): void {
if (!snapshot.exists || typeof snapshot.raw !== "string") {
return;
}
const stat = deps.fs.statSync(snapshot.path, { throwIfNoEntry: false }) ?? null;
const now = new Date().toISOString();
const current: ConfigHealthFingerprint = {
hash: resolveConfigSnapshotHash(snapshot) ?? hashConfigRaw(snapshot.raw),
bytes: Buffer.byteLength(snapshot.raw, "utf-8"),
mtimeMs: stat?.mtimeMs ?? null,
ctimeMs: stat?.ctimeMs ?? null,
hasMeta: hasConfigMeta(snapshot.parsed),
gatewayMode: resolveGatewayMode(snapshot.resolved),
observedAt: now,
};
let healthState = readConfigHealthStateSync(deps);
const entry = getConfigHealthEntry(healthState, snapshot.path);
const backupBaseline =
entry.lastKnownGood ??
readConfigFingerprintForPathSync(deps, `${snapshot.path}.bak`) ??
undefined;
const suspicious = resolveConfigObserveSuspiciousReasons({
bytes: current.bytes,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
parsed: snapshot.parsed,
lastKnownGood: backupBaseline,
});
if (suspicious.length === 0) {
if (snapshot.valid) {
const nextEntry: ConfigHealthEntry = {
lastKnownGood: current,
lastObservedSuspiciousSignature: null,
};
if (
!sameFingerprint(entry.lastKnownGood, current) ||
entry.lastObservedSuspiciousSignature !== null
) {
healthState = setConfigHealthEntry(healthState, snapshot.path, nextEntry);
writeConfigHealthStateSync(deps, healthState);
}
}
return;
}
const suspiciousSignature = `${current.hash}:${suspicious.join(",")}`;
if (entry.lastObservedSuspiciousSignature === suspiciousSignature) {
return;
}
const backup =
(backupBaseline?.hash ? backupBaseline : null) ??
readConfigFingerprintForPathSync(deps, `${snapshot.path}.bak`);
const clobberedPath = persistClobberedConfigSnapshotSync({
deps,
configPath: snapshot.path,
raw: snapshot.raw,
observedAt: now,
});
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
appendConfigAuditRecordSync(deps, {
ts: now,
source: "config-io",
event: "config.observe",
phase: "read",
configPath: snapshot.path,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
exists: true,
valid: snapshot.valid,
hash: current.hash,
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
});
healthState = setConfigHealthEntry(healthState, snapshot.path, {
...entry,
lastObservedSuspiciousSignature: suspiciousSignature,
});
writeConfigHealthStateSync(deps, healthState);
}
export type ConfigIoDeps = {
fs?: typeof fs;
json5?: typeof JSON5;
@@ -1052,6 +1247,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const configPath =
candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath;
function observeLoadConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSnapshot {
observeConfigSnapshotSync(deps, snapshot);
return snapshot;
}
function loadConfig(): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
@@ -1068,6 +1268,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
return {};
}
const raw = deps.fs.readFileSync(configPath, "utf-8");
const hash = hashConfigRaw(raw);
const parsed = deps.json5.parse(raw);
const readResolution = resolveConfigForRead(
resolveConfigIncludesForRead(parsed, configPath, deps),
@@ -1081,6 +1282,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw,
parsed,
resolved: {},
valid: true,
config: {},
hash,
issues: [],
warnings: [],
legacyIssues: [],
});
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
@@ -1092,6 +1306,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
const validated = validateConfigObjectWithPlugins(resolvedConfig);
if (!validated.ok) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw,
parsed,
resolved: coerceConfig(resolvedConfig),
valid: false,
config: coerceConfig(resolvedConfig),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
});
const details = validated.issues
.map(
(iss) =>
@@ -1130,6 +1357,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
);
normalizeConfigPaths(cfg);
normalizeExecSafeBinProfilesInConfig(cfg);
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw,
parsed,
resolved: coerceConfig(resolvedConfig),
valid: true,
config: cfg,
hash,
issues: [],
warnings: validated.warnings,
legacyIssues: findLegacyConfigIssues(resolvedConfig, parsed),
});
const duplicates = findDuplicateAgentDirs(cfg, {
env: deps.env,

View File

@@ -1,18 +1,10 @@
import fs from "node:fs";
import json5 from "json5";
import { resolveConfigPath } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
type LoggingConfig = OpenClawConfig["logging"];
export function readLoggingConfig(): LoggingConfig | undefined {
const configPath = resolveConfigPath();
try {
if (!fs.existsSync(configPath)) {
return undefined;
}
const raw = fs.readFileSync(configPath, "utf-8");
const parsed = json5.parse(raw);
const parsed = loadConfig();
const logging = parsed?.logging;
if (!logging || typeof logging !== "object" || Array.isArray(logging)) {
return undefined;