From 748d6821d2b6f7c4a35c341b3a8b9f263fec0801 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 01:36:06 +0000 Subject: [PATCH] fix(config): add forensic config write audit and watch attribution --- .../Sources/OpenClaw/OpenClawConfigFile.swift | 147 ++++++++++- .../OpenClawConfigFileTests.swift | 39 +++ scripts/watch-node.mjs | 6 + src/cli/gateway-cli/run.ts | 4 + src/config/io.ts | 235 ++++++++++++++++-- src/config/io.write-config.test.ts | 87 +++++++ 6 files changed, 490 insertions(+), 28 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 3f7d3c03aa5..fc66030e3f5 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -3,6 +3,7 @@ import Foundation enum OpenClawConfigFile { private static let logger = Logger(subsystem: "ai.openclaw", category: "config") + private static let configAuditFileName = "config-audit.jsonl" static func url() -> URL { OpenClawPaths.configURL @@ -35,15 +36,61 @@ enum OpenClawConfigFile { static func saveDict(_ dict: [String: Any]) { // Nix mode disables config writes in production, but tests rely on saving temp configs. if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + let url = self.url() + let previousData = try? Data(contentsOf: url) + let previousRoot = previousData.flatMap { self.parseConfigData($0) } + let previousBytes = previousData?.count + let hadMetaBefore = self.hasMeta(previousRoot) + let gatewayModeBefore = self.gatewayMode(previousRoot) + + var output = dict + self.stampMeta(&output) + do { - let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) - let url = self.url() + let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) try FileManager().createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) + let nextBytes = data.count + let gatewayModeAfter = self.gatewayMode(output) + let suspicious = self.configWriteSuspiciousReasons( + existsBefore: previousData != nil, + previousBytes: previousBytes, + nextBytes: nextBytes, + hadMetaBefore: hadMetaBefore, + gatewayModeBefore: gatewayModeBefore, + gatewayModeAfter: gatewayModeAfter) + if !suspicious.isEmpty { + self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") + } + self.appendConfigWriteAudit([ + "result": "success", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": nextBytes, + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "suspicious": suspicious, + ]) } catch { self.logger.error("config save failed: \(error.localizedDescription)") + self.appendConfigWriteAudit([ + "result": "failed", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": NSNull(), + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), + "suspicious": [], + "error": error.localizedDescription, + ]) } } @@ -214,4 +261,100 @@ enum OpenClawConfigFile { } return nil } + + private static func stampMeta(_ root: inout [String: Any]) { + var meta = root["meta"] as? [String: Any] ?? [:] + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app" + meta["lastTouchedVersion"] = version + meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date()) + root["meta"] = meta + } + + private static func hasMeta(_ root: [String: Any]?) -> Bool { + guard let root else { return false } + return root["meta"] is [String: Any] + } + + private static func hasMeta(_ root: [String: Any]) -> Bool { + root["meta"] is [String: Any] + } + + private static func gatewayMode(_ root: [String: Any]?) -> String? { + guard let root else { return nil } + return self.gatewayMode(root) + } + + private static func gatewayMode(_ root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let mode = gateway["mode"] as? String + else { return nil } + let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func configWriteSuspiciousReasons( + existsBefore: Bool, + previousBytes: Int?, + nextBytes: Int, + hadMetaBefore: Bool, + gatewayModeBefore: String?, + gatewayModeAfter: String?) -> [String] + { + var reasons: [String] = [] + if !existsBefore { + return reasons + } + if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) { + reasons.append("size-drop:\(previousBytes)->\(nextBytes)") + } + if !hadMetaBefore { + reasons.append("missing-meta-before-write") + } + if gatewayModeBefore != nil, gatewayModeAfter == nil { + reasons.append("gateway-mode-removed") + } + return reasons + } + + private static func configAuditLogURL() -> URL { + self.stateDirURL() + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent(self.configAuditFileName, isDirectory: false) + } + + private static func appendConfigWriteAudit(_ fields: [String: Any]) { + var record: [String: Any] = [ + "ts": ISO8601DateFormatter().string(from: Date()), + "source": "macos-openclaw-config-file", + "event": "config.write", + "pid": ProcessInfo.processInfo.processIdentifier, + "argv": Array(ProcessInfo.processInfo.arguments.prefix(8)), + ] + for (key, value) in fields { + record[key] = value is NSNull ? NSNull() : value + } + guard JSONSerialization.isValidJSONObject(record), + let data = try? JSONSerialization.data(withJSONObject: record) + else { + return + } + var line = Data() + line.append(data) + line.append(0x0A) + let logURL = self.configAuditLogURL() + do { + try FileManager().createDirectory( + at: logURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + if !FileManager().fileExists(atPath: logURL.path) { + FileManager().createFile(atPath: logURL.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: logURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + } catch { + // best-effort + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index c03505e2f4c..98e4e8046d3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -76,4 +76,43 @@ struct OpenClawConfigFileTests { #expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json") } } + + @MainActor + @Test + func saveDictAppendsConfigAuditLog() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") + + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": ["mode": "local"], + ]) + + let configData = try Data(contentsOf: configPath) + let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any] + #expect((configRoot?["meta"] as? [String: Any]) != nil) + + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let lines = rawAudit + .split(whereSeparator: \.isNewline) + .map(String.init) + #expect(!lines.isEmpty) + guard let last = lines.last else { + Issue.record("Missing config audit line") + return + } + let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any] + #expect(auditRoot?["source"] as? String == "macos-openclaw-config-file") + #expect(auditRoot?["event"] as? String == "config.write") + #expect(auditRoot?["result"] as? String == "success") + #expect(auditRoot?["configPath"] as? String == configPath.path) + } + } } diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index fc6d264677a..ad644b8727f 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -6,6 +6,12 @@ const args = process.argv.slice(2); const env = { ...process.env }; const cwd = process.cwd(); const compiler = "tsdown"; +const watchSession = `${Date.now()}-${process.pid}`; +env.OPENCLAW_WATCH_MODE = "1"; +env.OPENCLAW_WATCH_SESSION = watchSession; +if (args.length > 0) { + env.OPENCLAW_WATCH_COMMAND = args.join(" "); +} const initialBuild = spawnSync("pnpm", ["exec", compiler], { cwd, diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index e3a30bb52a2..2845197efe5 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -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; diff --git a/src/config/io.ts b/src/config/io.ts index fa306ee568f..a2d0b4c791e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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(); +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 { 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(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, + record: ConfigWriteAuditRecord, +): Promise { + 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 { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 4daf7434f88..59af3e99383 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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; + 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; + expect(last.watchMode).toBe(true); + expect(last.watchSession).toBe("watch-session-1"); + expect(last.watchCommand).toBe("gateway --force"); + }); + }); });