Skip to content

fix(memory-core): skip stale dreaming recall sources#71695

Merged
fabianwilliams merged 2 commits into
openclaw:mainfrom
MonkeyLeeT:codex/filter-live-memory-recalls
Apr 25, 2026
Merged

fix(memory-core): skip stale dreaming recall sources#71695
fabianwilliams merged 2 commits into
openclaw:mainfrom
MonkeyLeeT:codex/filter-live-memory-recalls

Conversation

@MonkeyLeeT

@MonkeyLeeT MonkeyLeeT commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Refs #70104.

  • Filter short-term recall entries to sources that still exist before light and REM dreaming use them.
  • Prevent REM output and phase-signal reinforcement from citing deleted session-corpus files.
  • Add a REM regression covering a stale memory/.dreams/session-corpus/*.txt recall next to a live daily memory note.

Scope

This addresses the missing-source stale recall path reproduced on current main. The original #70104 report also describes older signal-accumulation symptoms, so this PR intentionally does not claim to close the entire issue by itself.

Root Cause

Short-term recall entries were accepted based on memory path shape alone. Deep promotion already rehydrated source files before applying candidates, but light and REM dreaming read the recall store directly, so missing session-corpus entries could still appear in REM output and receive phase reinforcement.

Validation

  • OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test extensions/memory-core/src/dreaming-phases.test.ts
  • OPENCLAW_OXLINT_SKIP_LOCK=1 pnpm check:changed

@MonkeyLeeT MonkeyLeeT changed the title [codex] Skip stale memory recall sources in dreaming fix(memory-core): skip stale dreaming recall sources Apr 25, 2026
@MonkeyLeeT
MonkeyLeeT marked this pull request as ready for review April 25, 2026 18:11
@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@aisle-research-bot

aisle-research-bot Bot commented Apr 25, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Path traversal enables arbitrary file existence probing (and potential file read) via short-term recall entry paths
2 🟡 Medium Unbounded concurrent fs.stat calls in dreaming phases can exhaust I/O/file descriptors (DoS)
3 🟡 Medium Path traversal & dreaming DoS via unvalidated short-term recall entry paths (fs.stat rethrow)
1. 🟠 Path traversal enables arbitrary file existence probing (and potential file read) via short-term recall entry paths
Property Value
Severity High
CWE CWE-22
Location extensions/memory-core/src/short-term-promotion.ts:1369-1381

Description

filterLiveShortTermRecallEntries calls shortTermRecallSourceExists, which resolves filesystem paths from entry.path and checks them with fs.stat().

  • entry.path originates from stored short-term recall entries (readShortTermRecallEntries) and is only normalized by replacing \\ with / and stripping a leading ./.
  • resolveShortTermSourcePathCandidates() then uses path.resolve(workspaceDir, relativePath) on this untrusted/unchecked path.
  • If an attacker can influence the recall store JSON or the upstream result.path used to populate it, they can supply traversal such as memory/../../../../etc/passwd (or absolute paths), causing path.resolve() to escape workspaceDir.
  • The new code introduces an existence oracle (fs.stat) during dreaming runs; additionally, the same resolver is also used by rehydratePromotionCandidate() which performs fs.readFile() on the resolved path (turning traversal into arbitrary file read in that code path).

Vulnerable code:

const absolutePath = path.resolve(workspaceDir, relativePath);
const stat = await fs.stat(sourcePath);

Recommendation

Constrain resolved paths to an allowed root (e.g., <workspaceDir>/memory) and reject traversal/absolute inputs.

Example fix:

function resolveShortTermSourcePathCandidates(workspaceDir: string, candidatePath: string): string[] {
  const normalized = normalizeMemoryPath(candidatePath);// Reject absolute paths and any path with traversal segments
  const posix = path.posix.normalize(normalized);
  if (path.posix.isAbsolute(posix) || posix.split("/").includes("..")) {
    return [];
  }

  const allowedRoot = path.resolve(workspaceDir, "memory");
  const candidates = [posix.startsWith("memory/") ? posix : path.posix.join("memory", path.posix.basename(posix))];

  const resolved: string[] = [];
  for (const rel of candidates) {
    const abs = path.resolve(workspaceDir, rel);// Enforce containment within allowedRoot
    if (abs === allowedRoot || abs.startsWith(allowedRoot + path.sep)) {
      resolved.push(abs);
    }
  }
  return [...new Set(resolved)];
}

Also consider validating/sanitizing result.path at ingestion time (recordShortTermRecalls) and/or when loading the store (readStore) so malicious entries cannot persist.

2. 🟡 Unbounded concurrent fs.stat calls in dreaming phases can exhaust I/O/file descriptors (DoS)
Property Value
Severity Medium
CWE CWE-400
Location extensions/memory-core/src/short-term-promotion.ts:901-911

Description

filterLiveShortTermRecallEntries checks whether each short-term recall entry’s source file still exists by running filesystem stats for every entry concurrently via Promise.all.

Because runLightDreaming/runRemDreaming call this before applying the configured limit, a large short-term recall store (potentially attacker-inflated via repeated recalls or large workspaces) can trigger N concurrent fs.stat operations on every dreaming run. This can:

  • exhaust file descriptors / libuv threadpool
  • saturate disk / network-mounted filesystem I/O
  • block other extension operations (availability impact)

Vulnerable code:

const results = await Promise.all(
  params.entries.map(async (entry) => ({
    entry,
    exists: await shortTermRecallSourceExists({ workspaceDir: params.workspaceDir, entry }),
  })),
);

The risk is amplified because dreaming can be triggered on heartbeat (see runPhaseIfTriggered), allowing repeated invocations.

Recommendation

Add a concurrency limit (and ideally also a max-entry cap) for filesystem existence checks.

Example using p-limit:

import pLimit from "p-limit";

export async function filterLiveShortTermRecallEntries(params: {
  workspaceDir: string;
  entries: ShortTermRecallEntry[];
}): Promise<ShortTermRecallEntry[]> {
  const limit = pLimit(20); // tune based on environment

  const results = await Promise.all(
    params.entries.map((entry) =>
      limit(async () => ({
        entry,
        exists: await shortTermRecallSourceExists({ workspaceDir: params.workspaceDir, entry }),
      })),
    ),
  );

  return results.filter((r) => r.exists).map((r) => r.entry);
}

Additionally, consider:

  • applying slice(0, config.limit) (or another upper bound) before doing existence checks in dreaming phases
  • adding rate limiting/debouncing for heartbeat-triggered dreaming runs
3. 🟡 Path traversal & dreaming DoS via unvalidated short-term recall entry paths (fs.stat rethrow)
Property Value
Severity Medium
CWE CWE-22
Location extensions/memory-core/src/short-term-promotion.ts:1363-1381

Description

filterLiveShortTermRecallEntries calls shortTermRecallSourceExists, which resolves filesystem paths from ShortTermRecallEntry.path and executes fs.stat() on them.

Problems introduced/expanded by this change:

  • Path traversal / workspace escape: normalizeMemoryPath() does not remove .. segments, and isShortTermMemoryPath() matches any string containing /memory/<date>.md (not necessarily starting at workspace root). A crafted entry path like ../../memory/2024-01-01.md passes validation, and path.resolve(workspaceDir, relativePath) will resolve outside workspaceDir.
  • Availability (DoS) via rethrow: when fs.stat() hits non-ENOENT errors (e.g. EACCES, EPERM, ENOTDIR), the code rethrows, aborting the dreaming phase. The top-level dreaming runner logs the error, so a single crafted/bad entry can repeatedly break automated dreaming.
  • Potential internal path disclosure: the thrown error message from fs.stat() commonly includes the attempted absolute path, and is logged via formatErrorMessage(err) in the dreaming runner.

Vulnerable code:

const absolutePath = path.resolve(workspaceDir, relativePath);
...
const stat = await fs.stat(sourcePath);
...
if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
throw err;

Recommendation

Harden path handling and make existence checks non-fatal.

  1. Reject absolute paths and any path containing .. segments, and require the path to be under the intended memory root.
  2. Ensure resolved paths stay within workspaceDir before touching the filesystem.
  3. Treat common filesystem errors (EACCES, EPERM, ENOTDIR) as non-fatal (return false / skip) to avoid crashing dreaming.

Example fix:

function safeResolveWithinWorkspace(workspaceDir: string, rel: string): string | null {
  const normalized = rel.replaceAll("\\", "/");
  if (!normalized || path.posix.isAbsolute(normalized)) return null;
  const posixNorm = path.posix.normalize(normalized);
  if (posixNorm.startsWith("../") || posixNorm === ".." || posixNorm.includes("/../")) return null;

  const abs = path.resolve(workspaceDir, posixNorm);
  const ws = path.resolve(workspaceDir) + path.sep;
  if (!abs.startsWith(ws)) return null;
  return abs;
}// in shortTermRecallSourceExists
const abs = safeResolveWithinWorkspace(workspaceDir, relativePath);
if (!abs) continue;
try {
  const stat = await fs.stat(abs);
  if (stat.isFile()) return true;
} catch (err) {
  const code = (err as NodeJS.ErrnoException)?.code;
  if (code === "ENOENT" || code === "EACCES" || code === "EPERM" || code === "ENOTDIR") continue;
  return false; // or log debug and continue
}

Also consider validating/sanitizing ShortTermRecallEntry.path at ingestion time when reading/parsing the JSON store.


Analyzed PR: #71695 at commit 2a290ab

Last updated on: 2026-04-25T18:21:01Z

@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes stale session-corpus recall entries surfacing in light and REM dreaming output by adding a filterLiveShortTermRecallEntries helper that stat-checks each entry's backing file before it is consumed by either phase. The existing test is updated to create the backing file (so it still passes under the new filter) and to correct endLine to match the single-line fixture; a new regression test covers the stale-source scenario end-to-end.

Confidence Score: 4/5

Safe to merge; only one minor P2 style suggestion (sequential vs parallel stat calls), no correctness issues.

All logic changes are correct and consistent with existing patterns. The one finding is a P2 performance suggestion about parallelizing Promise.all in filterLiveShortTermRecallEntries; it does not affect correctness given the small typical entry count.

No files require special attention.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/memory-core/src/short-term-promotion.ts
Line: 901-911

Comment:
**Sequential `fs.stat` calls could be parallelized**

`filterLiveShortTermRecallEntries` checks each entry's file existence one at a time. For workspaces with many recall entries this adds unnecessary latency during dreaming. Using `Promise.all` would fan all the stat calls out concurrently.

```suggestion
export async function filterLiveShortTermRecallEntries(params: {
  workspaceDir: string;
  entries: ShortTermRecallEntry[];
}): Promise<ShortTermRecallEntry[]> {
  const results = await Promise.all(
    params.entries.map((entry) =>
      shortTermRecallSourceExists({ workspaceDir: params.workspaceDir, entry }).then(
        (exists) => ({ entry, exists }),
      ),
    ),
  );
  return results.filter(({ exists }) => exists).map(({ entry }) => entry);
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(memory-core): skip stale dreaming re..." | Re-trigger Greptile

Comment thread extensions/memory-core/src/short-term-promotion.ts Outdated

@fabianwilliams fabianwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering disappeared source files out of REM/Light dreaming candidates is the right scope — referencing deleted recall entries was polluting phase signals. filterLiveShortTermRecallEntries applied symmetrically across both phases. Test covers live-vs-stale separation cleanly. LGTM.

@fabianwilliams
fabianwilliams merged commit 8e83e52 into openclaw:main Apr 25, 2026
63 of 65 checks passed
ayesha-aziz123 pushed a commit to ayesha-aziz123/openclaw that referenced this pull request Apr 26, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
@MonkeyLeeT
MonkeyLeeT deleted the codex/filter-live-memory-recalls branch June 4, 2026 06:19
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
* fix(memory-core): skip stale dreaming recall sources

* fix(memory-core): parallelize live recall filtering
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants