Align QMD memory reads with canonical memory paths (#66026)

* fix(memory): align qmd read paths

Co-authored-by: zsx <git@zsxsoft.com>

* fix(memory): add qmd exact-path read fast path

* fix(memory): tighten qmd read-path guards

* changelog: note QMD memory_get canonical-path restriction (#66026)

---------

Co-authored-by: zsx <git@zsxsoft.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-14 08:58:27 -07:00
committed by GitHub
parent b4e38a7eb0
commit 37d5971db3
4 changed files with 114 additions and 23 deletions

View File

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

View File

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

View File

@@ -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",

View File

@@ -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<typeof resolveMemorySearchSyncConfig>;
private readonly managedCollectionNames: string[];
private readonly collectionRoots = new Map<string, CollectionRoot>();
private readonly sources = new Set<MemorySource>();
@@ -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