diff --git a/CHANGELOG.md b/CHANGELOG.md index dd18b97d38a..df4f4639f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues. +- Memory-core/QMD `memory_get`: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (`MEMORY.md`, `memory.md`, `DREAMS.md`, `dreams.md`, `memory/**`) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses `read` tool-policy denials. (#66026) Thanks @eleqtrizit. ## 2026.4.14 diff --git a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts index 115c058c6ed..958cfa54554 100644 --- a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts @@ -11,17 +11,17 @@ const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({ logInfoMock: vi.fn(), })); -type MockChild = EventEmitter & { +interface MockChild extends EventEmitter { stdout: EventEmitter; stderr: EventEmitter; kill: (signal?: NodeJS.Signals) => void; closeWith: (code?: number | null) => void; -}; +} function createMockChild(params?: { autoClose?: boolean }): MockChild { const stdout = new EventEmitter(); const stderr = new EventEmitter(); - const child = new EventEmitter() as MockChild; + const child = new EventEmitter() as unknown as MockChild; child.stdout = stdout; child.stderr = stderr; child.closeWith = (code = 0) => { @@ -123,14 +123,32 @@ describe("QmdMemoryManager slugified path resolution", () => { }) { const inner = params.manager as unknown as { db: { - prepare: (query: string) => { all: (...args: unknown[]) => unknown }; + prepare: (query: string) => { + get: (...args: unknown[]) => unknown; + all: (...args: unknown[]) => unknown; + }; close: () => void; }; }; inner.db = { prepare: (query: string) => ({ + get: (...args: unknown[]) => { + if (query.includes("collection = ? AND active = 1 AND path = ?")) { + expect(args[0]).toBe(params.collection); + const requestedPath = args[1]; + expect(typeof requestedPath).toBe("string"); + const exactCandidates = new Set([ + ...(params.exactPaths ?? []), + ...(params.actualPath ? [params.actualPath] : []), + ]); + return typeof requestedPath === "string" && exactCandidates.has(requestedPath) + ? { path: requestedPath } + : undefined; + } + throw new Error(`unexpected sqlite query: ${query}`); + }, all: (...args: unknown[]) => { - if (query.includes("collection = ? AND path = ?")) { + if (query.includes("collection = ? AND path = ? AND active = 1")) { expect(args).toEqual([params.collection, params.normalizedPath]); return (params.exactPaths ?? []).map((pathValue) => ({ path: pathValue })); } diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 2d19abe5d90..4b7c511c8dc 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -3559,14 +3559,31 @@ describe("QmdMemoryManager", () => { await manager.close(); }); - it("reads only requested line ranges without loading the whole file", async () => { - const readFileSpy = vi.spyOn(fs, "readFile"); - const text = Array.from({ length: 50 }, (_, index) => `line-${index + 1}`).join("\n"); - await fs.writeFile(path.join(workspaceDir, "window.md"), text, "utf-8"); + it("rejects non-memory workspace markdown reads", async () => { + await fs.writeFile(path.join(workspaceDir, "window.md"), "secret", "utf-8"); + await fs.mkdir(path.join(workspaceDir, ".memory"), { recursive: true }); + await fs.writeFile(path.join(workspaceDir, ".memory", "hidden.md"), "secret", "utf-8"); const { manager } = await createManager(); - const result = await manager.readFile({ relPath: "window.md", from: 10, lines: 3 }); + await expect(manager.readFile({ relPath: "window.md" })).rejects.toThrow("path required"); + await expect(manager.readFile({ relPath: ".memory/hidden.md" })).rejects.toThrow( + "path required", + ); + + await manager.close(); + }); + + it("reads only requested line ranges from canonical memory files without loading the whole file", async () => { + const readFileSpy = vi.spyOn(fs, "readFile"); + const text = Array.from({ length: 50 }, (_, index) => `line-${index + 1}`).join("\n"); + const relPath = path.join("memory", "window.md"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + await fs.writeFile(path.join(workspaceDir, relPath), text, "utf-8"); + + const { manager } = await createManager(); + + const result = await manager.readFile({ relPath, from: 10, lines: 3 }); expect(result.text).toBe("line-10\nline-11\nline-12"); expect(readFileSpy).not.toHaveBeenCalled(); @@ -3575,15 +3592,16 @@ describe("QmdMemoryManager", () => { }); it("returns empty text when qmd files are missing before or during read", async () => { - const relPath = "qmd-window.md"; + const relPath = path.join("memory", "qmd-window.md"); const absPath = path.join(workspaceDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8"); const cases = [ { name: "missing before read", - request: { relPath: "ghost.md" }, - expectedPath: "ghost.md", + request: { relPath: path.join("memory", "ghost.md") }, + expectedPath: path.join("memory", "ghost.md"), }, { name: "disappears before partial read", diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index ee4cbca9bf4..41fe8ecf941 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -15,7 +15,6 @@ import { resolveStateDir, writeFileWithinRoot, type OpenClawConfig, - type ResolvedMemorySearchSyncConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; import { buildSessionEntry, @@ -82,6 +81,22 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([ "__pycache__", ]); +function isDefaultMemoryPath(relPath: string): boolean { + const normalized = relPath.trim().replace(/^\.\//, "").replace(/\\/g, "/"); + if (!normalized) { + return false; + } + if ( + normalized === "MEMORY.md" || + normalized === "memory.md" || + normalized === "DREAMS.md" || + normalized === "dreams.md" + ) { + return true; + } + return normalized.startsWith("memory/"); +} + function buildQmdProcessPath(rawPath: string | undefined): string { const nodeBinDir = path.dirname(process.execPath); const entries = rawPath?.split(path.delimiter).filter(Boolean) ?? []; @@ -256,7 +271,7 @@ export class QmdMemoryManager implements MemorySearchManager { private readonly xdgCacheHome: string; private readonly indexPath: string; private readonly env: NodeJS.ProcessEnv; - private readonly syncSettings: ResolvedMemorySearchSyncConfig | null; + private readonly syncSettings: ReturnType; private readonly managedCollectionNames: string[]; private readonly collectionRoots = new Map(); private readonly sources = new Set(); @@ -1189,14 +1204,7 @@ export class QmdMemoryManager implements MemorySearchManager { if (full.missing) { return { text: "", path: relPath }; } - if (!params.from && !params.lines) { - return { text: full.text, path: relPath }; - } - const lines = full.text.split("\n"); - const start = Math.max(1, params.from ?? 1); - const count = Math.max(1, params.lines ?? lines.length); - const slice = lines.slice(start - 1, start - 1 + count); - return { text: slice.join("\n"), path: relPath }; + return { text: full.text, path: relPath }; } status(): MemoryProviderStatus { @@ -2518,9 +2526,55 @@ export class QmdMemoryManager implements MemorySearchManager { if (!this.isWithinWorkspace(absPath)) { throw new Error("path escapes workspace"); } + const workspaceRel = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/"); + if (!isDefaultMemoryPath(workspaceRel) && !this.isIndexedWorkspaceReadPath(absPath)) { + throw new Error("path required"); + } return absPath; } + private isIndexedWorkspaceReadPath(absPath: string): boolean { + const normalizedAbsPath = path.normalize(absPath); + for (const [collection, root] of this.collectionRoots.entries()) { + if (!this.isWithinRoot(root.path, normalizedAbsPath)) { + continue; + } + const collectionRelativePath = path + .relative(root.path, normalizedAbsPath) + .replace(/\\/g, "/"); + if (!collectionRelativePath || collectionRelativePath.startsWith("..")) { + continue; + } + try { + const exactRow = this.ensureDb() + .prepare("SELECT path FROM documents WHERE collection = ? AND active = 1 AND path = ?") + .get(collection, collectionRelativePath) as { path: string } | undefined; + if ( + exactRow && + path.normalize(path.resolve(root.path, exactRow.path)) === normalizedAbsPath + ) { + return true; + } + const rows = this.ensureDb() + .prepare("SELECT path FROM documents WHERE collection = ? AND active = 1") + .all(collection) as Array<{ path: string }>; + const match = rows.find((row) => + this.matchesPreferredFileHint(row.path, collectionRelativePath), + ); + if (match && path.normalize(path.resolve(root.path, match.path)) === normalizedAbsPath) { + return true; + } + } catch (err) { + if (this.isSqliteBusyError(err)) { + log.debug(`qmd index is busy while checking read path: ${String(err)}`); + throw this.createQmdBusyError(err); + } + log.debug(`qmd indexed read-path lookup skipped: ${String(err)}`); + } + } + return false; + } + private isWithinWorkspace(absPath: string): boolean { const normalizedWorkspace = this.workspaceDir.endsWith(path.sep) ? this.workspaceDir