fix(config): add forensic config write audit and watch attribution

This commit is contained in:
Peter Steinberger
2026-02-14 01:36:06 +00:00
parent 3b5a9c14dd
commit 748d6821d2
6 changed files with 490 additions and 28 deletions

View File

@@ -1,11 +1,13 @@
import type { Command } from "commander";
import fs from "node:fs";
import path from "node:path";
import type { GatewayAuthMode } from "../../config/config.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
import {
CONFIG_PATH,
loadConfig,
readConfigFileSnapshot,
resolveStateDir,
resolveGatewayPort,
} from "../../config/config.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
@@ -160,6 +162,7 @@ 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;
if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) {
@@ -170,6 +173,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.error(
`Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`,
);
defaultRuntime.error(`Config write audit: ${configAuditPath}`);
}
defaultRuntime.exit(1);
return;

View File

@@ -68,8 +68,40 @@ const SHELL_ENV_EXPECTED_KEYS = [
];
const CONFIG_BACKUP_COUNT = 5;
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
const loggedInvalidConfigs = new Set<string>();
type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
type ConfigWriteAuditRecord = {
ts: string;
source: "config-io";
event: "config.write";
result: ConfigWriteAuditResult;
configPath: string;
pid: number;
ppid: number;
cwd: string;
argv: string[];
execArgv: string[];
watchMode: boolean;
watchSession: string | null;
watchCommand: string | null;
existsBefore: boolean;
previousHash: string | null;
nextHash: string | null;
previousBytes: number | null;
nextBytes: number | null;
changedPathCount: number | null;
hasMetaBefore: boolean;
hasMetaAfter: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
suspicious: string[];
errorCode?: string;
errorMessage?: string;
};
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
export type ConfigWriteOptions = {
/**
@@ -123,6 +155,26 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasConfigMeta(value: unknown): boolean {
if (!isPlainObject(value)) {
return false;
}
const meta = value.meta;
return isPlainObject(meta);
}
function resolveGatewayMode(value: unknown): string | null {
if (!isPlainObject(value)) {
return null;
}
const gateway = value.gateway;
if (!isPlainObject(gateway) || typeof gateway.mode !== "string") {
return null;
}
const trimmed = gateway.mode.trim();
return trimmed.length > 0 ? trimmed : null;
}
function cloneUnknown<T>(value: T): T {
return structuredClone(value);
}
@@ -307,6 +359,55 @@ async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises)
});
}
function resolveConfigAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_AUDIT_LOG_FILENAME);
}
function resolveConfigWriteSuspiciousReasons(params: {
existsBefore: boolean;
previousBytes: number | null;
nextBytes: number | null;
hasMetaBefore: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
}): string[] {
const reasons: string[] = [];
if (!params.existsBefore) {
return reasons;
}
if (
typeof params.previousBytes === "number" &&
typeof params.nextBytes === "number" &&
params.previousBytes >= 512 &&
params.nextBytes < Math.floor(params.previousBytes * 0.5)
) {
reasons.push(`size-drop:${params.previousBytes}->${params.nextBytes}`);
}
if (!params.hasMetaBefore) {
reasons.push("missing-meta-before-write");
}
if (params.gatewayModeBefore && !params.gatewayModeAfter) {
reasons.push("gateway-mode-removed");
}
return reasons;
}
async function appendConfigWriteAuditRecord(
deps: Required<ConfigIoDeps>,
record: ConfigWriteAuditRecord,
): Promise<void> {
try {
const auditPath = resolveConfigAuditLogPath(deps.env, deps.homedir);
await deps.fs.promises.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 });
await deps.fs.promises.appendFile(auditPath, `${JSON.stringify(record)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}
export type ConfigIoDeps = {
fs?: typeof fs;
json5?: typeof JSON5;
@@ -822,10 +923,26 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
: cfgToWrite;
// Do NOT apply runtime defaults when writing — user config should only contain
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
const json = JSON.stringify(stampConfigVersion(outputConfig), null, 2).trimEnd().concat("\n");
const stampedOutputConfig = stampConfigVersion(outputConfig);
const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n");
const nextHash = hashConfigRaw(json);
const previousHash = resolveConfigSnapshotHash(snapshot);
const changedPathCount = changedPaths?.size;
const previousBytes =
typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null;
const nextBytes = Buffer.byteLength(json, "utf-8");
const hasMetaBefore = hasConfigMeta(snapshot.parsed);
const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
const gatewayModeAfter = resolveGatewayMode(stampedOutputConfig);
const suspiciousReasons = resolveConfigWriteSuspiciousReasons({
existsBefore: snapshot.exists,
previousBytes,
nextBytes,
hasMetaBefore,
gatewayModeBefore,
gatewayModeAfter,
});
const logConfigOverwrite = () => {
if (!snapshot.exists) {
return;
@@ -841,46 +958,112 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
`Config overwrite: ${configPath} (sha256 ${previousHash ?? "unknown"} -> ${nextHash}, backup=${configPath}.bak${changeSummary})`,
);
};
const logConfigWriteAnomalies = () => {
if (suspiciousReasons.length === 0) {
return;
}
deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`);
};
const auditRecordBase = {
ts: new Date().toISOString(),
source: "config-io" as const,
event: "config.write" as const,
configPath,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
watchMode: deps.env.OPENCLAW_WATCH_MODE === "1",
watchSession:
typeof deps.env.OPENCLAW_WATCH_SESSION === "string" &&
deps.env.OPENCLAW_WATCH_SESSION.trim().length > 0
? deps.env.OPENCLAW_WATCH_SESSION.trim()
: null,
watchCommand:
typeof deps.env.OPENCLAW_WATCH_COMMAND === "string" &&
deps.env.OPENCLAW_WATCH_COMMAND.trim().length > 0
? deps.env.OPENCLAW_WATCH_COMMAND.trim()
: null,
existsBefore: snapshot.exists,
previousHash: previousHash ?? null,
nextHash,
previousBytes,
nextBytes,
changedPathCount: typeof changedPathCount === "number" ? changedPathCount : null,
hasMetaBefore,
hasMetaAfter,
gatewayModeBefore,
gatewayModeAfter,
suspicious: suspiciousReasons,
};
const appendWriteAudit = async (result: ConfigWriteAuditResult, err?: unknown) => {
const errorCode =
err && typeof err === "object" && "code" in err && typeof err.code === "string"
? err.code
: undefined;
const errorMessage =
err && typeof err === "object" && "message" in err && typeof err.message === "string"
? err.message
: undefined;
await appendConfigWriteAuditRecord(deps, {
...auditRecordBase,
result,
nextHash: result === "failed" ? null : auditRecordBase.nextHash,
nextBytes: result === "failed" ? null : auditRecordBase.nextBytes,
errorCode,
errorMessage,
});
};
const tmp = path.join(
dir,
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
await deps.fs.promises.writeFile(tmp, json, {
encoding: "utf-8",
mode: 0o600,
});
if (deps.fs.existsSync(configPath)) {
await rotateConfigBackups(configPath, deps.fs.promises);
await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
// best-effort
});
}
try {
await deps.fs.promises.rename(tmp, configPath);
} catch (err) {
const code = (err as { code?: string }).code;
// Windows doesn't reliably support atomic replace via rename when dest exists.
if (code === "EPERM" || code === "EEXIST") {
await deps.fs.promises.copyFile(tmp, configPath);
await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
await deps.fs.promises.writeFile(tmp, json, {
encoding: "utf-8",
mode: 0o600,
});
if (deps.fs.existsSync(configPath)) {
await rotateConfigBackups(configPath, deps.fs.promises);
await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
// best-effort
});
}
try {
await deps.fs.promises.rename(tmp, configPath);
} catch (err) {
const code = (err as { code?: string }).code;
// Windows doesn't reliably support atomic replace via rename when dest exists.
if (code === "EPERM" || code === "EEXIST") {
await deps.fs.promises.copyFile(tmp, configPath);
await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
// best-effort
});
await deps.fs.promises.unlink(tmp).catch(() => {
// best-effort
});
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit("copy-fallback");
return;
}
await deps.fs.promises.unlink(tmp).catch(() => {
// best-effort
});
logConfigOverwrite();
return;
throw err;
}
await deps.fs.promises.unlink(tmp).catch(() => {
// best-effort
});
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit("rename");
} catch (err) {
await appendWriteAudit("failed", err);
throw err;
}
logConfigOverwrite();
}
return {

View File

@@ -311,4 +311,91 @@ describe("config io write", () => {
expect(overwriteLogs).toHaveLength(0);
});
});
it("appends config write audit JSONL entries with forensic metadata", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { port: 18789 } }, null, 2),
"utf-8",
);
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger: {
warn: vi.fn(),
error: vi.fn(),
},
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.valid).toBe(true);
const next = structuredClone(snapshot.config);
next.gateway = {
...next.gateway,
mode: "local",
};
await io.writeConfigFile(next);
const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
const last = JSON.parse(lines.at(-1) ?? "{}") as Record<string, unknown>;
expect(last.source).toBe("config-io");
expect(last.event).toBe("config.write");
expect(last.configPath).toBe(configPath);
expect(last.existsBefore).toBe(true);
expect(last.hasMetaAfter).toBe(true);
expect(last.previousHash).toBeTypeOf("string");
expect(last.nextHash).toBeTypeOf("string");
expect(last.result === "rename" || last.result === "copy-fallback").toBe(true);
});
});
it("records gateway watch session markers in config audit entries", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { mode: "local" } }, null, 2),
"utf-8",
);
const io = createConfigIO({
env: {
OPENCLAW_WATCH_MODE: "1",
OPENCLAW_WATCH_SESSION: "watch-session-1",
OPENCLAW_WATCH_COMMAND: "gateway --force",
} as NodeJS.ProcessEnv,
homedir: () => home,
logger: {
warn: vi.fn(),
error: vi.fn(),
},
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.valid).toBe(true);
const next = structuredClone(snapshot.config);
next.gateway = {
...next.gateway,
bind: "loopback",
};
await io.writeConfigFile(next);
const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean);
const last = JSON.parse(lines.at(-1) ?? "{}") as Record<string, unknown>;
expect(last.watchMode).toBe(true);
expect(last.watchSession).toBe("watch-session-1");
expect(last.watchCommand).toBe("gateway --force");
});
});
});