From 9f111c73658cd4763889ad1de0a7af61c12df2c2 Mon Sep 17 00:00:00 2001 From: OpenClaw Explorer Date: Sun, 1 Mar 2026 06:48:33 +0800 Subject: [PATCH] feat: memory change audit log + memory_audit tool Adds a lightweight audit trail for memory file changes, addressing the 'memory tampering is the existential threat' concern from the reflections. ## Changes ### src/memory/memory-schema.ts - New `file_changes` table: records (path, old_hash, new_hash, changed_at, source) for every memory file update - Index on (path, changed_at DESC) for efficient per-file history queries ### src/memory/manager-sync-ops.ts - Before calling `indexFile()` for a changed memory file, insert a row into `file_changes` with the old and new hash. This creates an immutable audit trail of every hash transition the agent observed. ### src/agents/tools/memory-tool.ts - New `memory_audit` tool: queries `file_changes` via read-only SQLite access - Parameters: optional `path` filter + `limit` (default 20, max 200) - Returns: array of change records with timestamps and hash values - Agents can now answer 'when was MEMORY.md last changed?' and detect unexpected modifications between sessions Fixes improvement items #4 and #11 from openclaw-improvement-ideas.md. --- src/agents/tools/memory-tool.ts | 72 +++++++++++++++++++++++++++++++++ src/memory/manager-sync-ops.ts | 7 ++++ src/memory/memory-schema.ts | 14 +++++++ 3 files changed, 93 insertions(+) diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts index c0d595b21a2..f77f2f607fe 100644 --- a/src/agents/tools/memory-tool.ts +++ b/src/agents/tools/memory-tool.ts @@ -240,3 +240,75 @@ function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | } return "direct"; } + +export function createMemoryAuditTool(options: { + cfg: OpenClawConfig; + workspaceDir: string; + sessionKey?: string; +}): AnyAgentTool { + return { + name: "memory_audit", + description: + "Query change history for memory files (MEMORY.md, memory/*.md). Returns recent hash changes recorded when files were indexed. Use to detect unexpected modifications or verify when a file was last updated by the agent.", + parameters: Type.Object({ + path: Type.Optional( + Type.String({ + description: "Relative path to filter (e.g. 'MEMORY.md'). Omit to list all recent changes.", + }), + ), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of changes to return (default 20).", + minimum: 1, + maximum: 200, + }), + ), + }), + execute: async (_, params) => { + const { manager, error } = await getMemorySearchManager({ + cfg: options.cfg, + workspaceDir: options.workspaceDir, + agentId: resolveSessionAgentId(options.sessionKey), + }); + if (error || !manager) { + return jsonResult({ error: error ?? "Memory manager unavailable" }); + } + const dbPath = manager.dbPath; + if (!dbPath) { + return jsonResult({ error: "Memory index path unavailable" }); + } + try { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(dbPath, { readOnly: true }); + const limit = readNumberParam(params, "limit") ?? 20; + const filterPath = readStringParam(params, "path"); + let rows: unknown[]; + if (filterPath) { + rows = db + .prepare( + `SELECT path, old_hash, new_hash, changed_at, source + FROM file_changes + WHERE path = ? + ORDER BY changed_at DESC + LIMIT ?`, + ) + .all(filterPath, limit); + } else { + rows = db + .prepare( + `SELECT path, old_hash, new_hash, changed_at, source + FROM file_changes + ORDER BY changed_at DESC + LIMIT ?`, + ) + .all(limit); + } + db.close(); + return jsonResult({ changes: rows, count: rows.length }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return jsonResult({ error: `Failed to query file_changes: ${message}` }); + } + }, + }; +} diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index e6189f8d21a..5cf0c40fd18 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -671,6 +671,13 @@ export abstract class MemoryManagerSyncOps { } return; } + // Audit: record the hash change before indexing + this.db + .prepare( + `INSERT INTO file_changes (path, old_hash, new_hash, changed_at, source) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(entry.path, record?.hash ?? null, entry.hash, Date.now(), "agent"); await this.indexFile(entry, { source: "memory" }); if (params.progress) { params.progress.completed += 1; diff --git a/src/memory/memory-schema.ts b/src/memory/memory-schema.ts index a537c35f171..5433bbf6ffb 100644 --- a/src/memory/memory-schema.ts +++ b/src/memory/memory-schema.ts @@ -20,6 +20,20 @@ export function ensureMemoryIndexSchema(params: { mtime INTEGER NOT NULL, size INTEGER NOT NULL ); + \`); + params.db.exec(\` + CREATE TABLE IF NOT EXISTS file_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + old_hash TEXT, + new_hash TEXT NOT NULL, + changed_at INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'agent' + ); + \`); + params.db.exec(\` + CREATE INDEX IF NOT EXISTS file_changes_path_idx + ON file_changes (path, changed_at DESC); `); params.db.exec(` CREATE TABLE IF NOT EXISTS chunks (