mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
fix: audit clobbered config reads
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
244
src/config/io.ts
244
src/config/io.ts
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user