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.
This commit is contained in:
OpenClaw Explorer 2026-03-01 06:48:33 +08:00
parent 66e61ca6ce
commit 9f111c7365
3 changed files with 93 additions and 0 deletions

View File

@ -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}` });
}
},
};
}

View File

@ -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;

View File

@ -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 (