fix(session-storage): bound getSessionMessages cache to prevent unbounded growth#1288
fix(session-storage): bound getSessionMessages cache to prevent unbounded growth#1288Ericcc-Ma wants to merge 3 commits into
Conversation
…tion
`validatePath` previously used platform-specific `path.isAbsolute` and
`path.resolve`. On Windows, this created a sandbox-escape class:
- A model emitting `/c/Users/foo/sensitive.txt` (MinGW form, the way
Git Bash writes paths) reaches `validatePath`.
- `path.isAbsolute('/c/Users/foo/sensitive.txt')` returns true on
Windows (treated as a drive-relative absolute path).
- `path.resolve('D:\\project', '/c/Users/foo/sensitive.txt')` returns
`D:\\c\\Users\\foo\\sensitive.txt` (resolved against the current drive).
- The validator compares `D:\\c\\Users\\foo\\sensitive.txt` against the
allowed-directories list. If `D:\\` is broadly allowed (e.g. the
user's working directory is on D:), validation passes.
- Git Bash actually writes to `C:\\Users\\foo\\sensitive.txt` — a
completely different filesystem location — bypassing the sandbox.
The fix: on Windows, normalize MinGW-style absolute paths
(`/c/Users/foo`, `/cygdrive/c/Users/foo`) to Windows paths
(`C:\\Users\\foo`) via `posixPathToWindowsPath` BEFORE the
isAbsolute/resolve step. The validator's path space now matches the
shell's, so the comparison is against the actual target the shell will
write to.
`posixPathToWindowsPath` is a no-op for already-Windows paths and
relative paths (it just flips slashes), so applying it
unconditionally on Windows is safe.
Test coverage added in
`src/utils/permissions/__tests__/pathValidation.test.ts`:
- `/c/...` → `C:\\...` conversion (all 7 cases incl. drive-letter case,
bare mount, nested paths)
- `/cygdrive/c/...` → `C:\\...` conversion
- Already-Windows paths pass through unchanged
- Relative paths pass through (just slash direction flipped)
- SECURITY regression test: MinGW path that escapes allowed dirs is
now correctly denied at the right location
- SECURITY regression test: cygdrive variant of the above
Verified on Windows 11 + Bun 1.3.14:
- `bun run precheck`: 5906/5906 pass (was 5896/5896, +10 new tests)
- `bun run build`: succeeds
Co-Authored-By: Claude <noreply@anthropic.com>
… warning CodeRabbit flagged the PR with "Docstring coverage is 50.00% which is insufficient (threshold 80.00%)". Added JSDoc to: 1. `formatDirectoryList` in pathValidation.ts — the only production function in the changed files that lacked JSDoc. Brief description of the truncation behavior + reference to MAX_DIRS_TO_LIST. 2. Both `describeIfWindows` blocks in pathValidation.test.ts — explain the security rationale of the MinGW-normalization fix and the sandbox-escape regression scenarios. Individual `test()` calls already self-document via their string descriptions. Co-Authored-By: Claude <noreply@anthropic.com>
…nded growth `getSessionMessages` was implemented via `lodash memoize`, which has no size cap. Each unique sessionId adds a `Promise<Set<UUID>>` entry that is only cleared by `clearSessionMessagesCache()` — and that call is only made from `postCompactCleanup`, which fires once per compaction. In long-running daemon / swarm sessions that spawn many agents (each with its own transcript), the cache grows unbounded for the process lifetime. Per-entry Sets are MUCH larger than the file-path strings the adjacent `existingSessionFiles` cache stores, so the leak is more severe than the one fixed for that cache at `sessionStorage.ts:535`. The fix mirrors the existing `existingSessionFiles` pattern: replace lodash `memoize` with a `Map<UUID, Promise<Set<UUID>>>` and evict the oldest entry (FIFO via Map insertion order) when at `MAX_CACHED_SESSION_FILES` capacity. Re-uses the same constant for consistency. Concretely: - `sessionStorage.ts`: refactor `getSessionMessages` to be a regular `async function` backed by a module-level `Map`, with FIFO eviction at `MAX_CACHED_SESSION_FILES`. Export `getSessionMessagesCache()` so `getLastSessionLog`'s priming logic (and tests) can interact with the underlying Map directly. Export `getSessionMessages` itself with a `@internal`-style comment noting it's not part of the public API (callers should keep using `doesMessageExistInSession`). - `src/utils/__tests__/sessionStorage.test.ts` (new): 7 tests covering cache identity, clear behavior, concurrent-call dedup, bounded size after many distinct sessionIds, FIFO eviction order, and refill after clear. Uses real temp directory + `CLAUDE_CONFIG_DIR` env to keep tests hermetic — no `mock.module` pollution. Verified on Windows 11 + Bun 1.3.14: - `bun run precheck`: 5899/5900 pass (the 1 fail is a pre-existing flaky `toolEventObserver` test that times out on Windows runners, unrelated to this change) - 7/7 new tests pass - No regressions in other test files Co-Authored-By: Claude <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdded session-message cache tests and Windows path-validation tests, while refactoring ChangesSession message cache
Windows path validation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
src/utils/permissions/__tests__/pathValidation.test.ts (2)
5-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove module mocks into the shared
beforeAllmock pattern.These are bottom-layer mocks, but this file registers them at module scope and inlines the
bun:bundlemock. Please align with the shared mock setup to avoid cross-file mock pollution. As per coding guidelines,**/__tests__/**/*.test.{ts,tsx}should “Mock only bottom-layer dependencies with side effects” and “Useuse-shared-mockpattern: import mock from tests/mocks/ … and pass to mock.module() in beforeAll block”.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/permissions/__tests__/pathValidation.test.ts` around lines 5 - 10, Move the bottom-layer mocks for src/utils/log.ts, src/utils/debug.ts, and bun:bundle out of module scope and into the shared beforeAll mock setup used by pathValidation.test.ts. Update the test to use the existing shared mock pattern by importing the mock from tests/mocks and registering it with mock.module() inside beforeAll, so the test avoids cross-file mock pollution while keeping the same behavior.Source: Coding guidelines
173-184: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winSeed an allow-list that would have exposed the old escape.
With
makeContext()empty,result.allowedis false even before this fix. To prove the sandbox-escape regression, configure the context so the old wrong resolution underD:\...would be allowed while the normalizedC:\Users\foo...path is denied.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/permissions/__tests__/pathValidation.test.ts` around lines 173 - 184, The test in validatePath should not use an empty makeContext(), because that already denies the path before the regression is exercised. Update the pathValidation.test.ts case to seed the context allow-list so the old Windows/MinGW mis-resolution under the D:\... base would have passed, while the corrected normalized path to C:\Users\foo\sensitive.txt is denied; keep the assertion focused on validatePath and the allowed/denied outcome for the escape scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/utils/permissions/pathValidation.ts`:
- Around line 482-502: The Windows path normalization in pathValidation should
be applied before both the glob and non-glob validation flows, not only before
isAbsolute/resolve. Update the path handling around validateGlobPattern and the
later resolve logic so MinGW-style absolute paths are normalized to Windows
paths first, ensuring inputs like /c/Users/foo/*.txt are validated against the
same filesystem location Git Bash uses.
In `@src/utils/sessionStorage.ts`:
- Around line 4030-4032: The session cache priming in
getLastSessionLog()/getSessionMessagesCache() needs the original “cache is
empty” guard restored, not just a per-session has(sessionId) check. Update the
priming logic so it only seeds from disk when the entire messages cache is
empty, preventing stale snapshots from being written into a session that already
has cached entries and avoiding UUID dedup misses for unflushed messages.
---
Nitpick comments:
In `@src/utils/permissions/__tests__/pathValidation.test.ts`:
- Around line 5-10: Move the bottom-layer mocks for src/utils/log.ts,
src/utils/debug.ts, and bun:bundle out of module scope and into the shared
beforeAll mock setup used by pathValidation.test.ts. Update the test to use the
existing shared mock pattern by importing the mock from tests/mocks and
registering it with mock.module() inside beforeAll, so the test avoids
cross-file mock pollution while keeping the same behavior.
- Around line 173-184: The test in validatePath should not use an empty
makeContext(), because that already denies the path before the regression is
exercised. Update the pathValidation.test.ts case to seed the context allow-list
so the old Windows/MinGW mis-resolution under the D:\... base would have passed,
while the corrected normalized path to C:\Users\foo\sensitive.txt is denied;
keep the assertion focused on validatePath and the allowed/denied outcome for
the escape scenario.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5ae03700-a312-467b-a868-1cb7b7487cf5
📒 Files selected for processing (4)
src/utils/__tests__/sessionStorage.test.tssrc/utils/permissions/__tests__/pathValidation.test.tssrc/utils/permissions/pathValidation.tssrc/utils/sessionStorage.ts
| // SECURITY: On Windows, normalize MinGW-style absolute paths | ||
| // (`/c/Users/foo`, `/cygdrive/c/Users/foo`) to Windows paths | ||
| // (`C:\Users\foo`) BEFORE the isAbsolute/resolve step below. | ||
| // | ||
| // Without this, `path.isAbsolute('/c/Users/foo')` returns true on | ||
| // Windows (interpreted as a drive-relative absolute path) and | ||
| // `path.resolve(cwd, '/c/Users/foo')` produces `<currentDrive>:\c\Users\foo` | ||
| // — a completely different filesystem location from what Git Bash | ||
| // actually writes to (`C:\Users\foo`). The validator would compare | ||
| // the wrong path against the allowed-directories list, enabling a | ||
| // sandbox escape where `/c/Users/foo/sensitive.txt` passes validation | ||
| // when `C:\Users\foo\sensitive.txt` is not in any allowed dir. | ||
| // | ||
| // `posixPathToWindowsPath` is a no-op for already-Windows paths and | ||
| // for relative paths (it just flips slashes), so it's safe to apply | ||
| // unconditionally on Windows. | ||
| const pathForResolve = | ||
| getPlatform() === 'windows' ? posixPathToWindowsPath(cleanPath) : cleanPath | ||
| const absolutePath = isAbsolute(pathForResolve) | ||
| ? pathForResolve | ||
| : resolve(cwd, pathForResolve) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Apply the Windows normalization before the glob branch as well.
Line 498 only affects the non-glob path flow, but read globs return earlier through validateGlobPattern(cleanPath, ...) on Lines 472-478. A Windows input like /c/Users/foo/*.txt can still be resolved/authorized in the wrong path space while Git Bash expands it under C:\Users\foo.
Suggested direction
+ const pathForResolve =
+ getPlatform() === 'windows' ? posixPathToWindowsPath(cleanPath) : cleanPath
+
- if (GLOB_PATTERN_REGEX.test(cleanPath)) {
+ if (GLOB_PATTERN_REGEX.test(pathForResolve)) {
if (operationType === 'write' || operationType === 'create') {
return {
allowed: false,
- resolvedPath: cleanPath,
+ resolvedPath: pathForResolve,
decisionReason: {
type: 'other',
reason:
'Glob patterns are not allowed in write operations. Please specify an exact file path.',
},
}
}
return validateGlobPattern(
- cleanPath,
+ pathForResolve,
cwd,
toolPermissionContext,
operationType,
)
}
- const pathForResolve =
- getPlatform() === 'windows' ? posixPathToWindowsPath(cleanPath) : cleanPath🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/utils/permissions/pathValidation.ts` around lines 482 - 502, The Windows
path normalization in pathValidation should be applied before both the glob and
non-glob validation flows, not only before isAbsolute/resolve. Update the path
handling around validateGlobPattern and the later resolve logic so MinGW-style
absolute paths are normalized to Windows paths first, ensuring inputs like
/c/Users/foo/*.txt are validated against the same filesystem location Git Bash
uses.
| const messagesCache = getSessionMessagesCache() | ||
| if (!messagesCache.has(sessionId)) { | ||
| messagesCache.set(sessionId, Promise.resolve(new Set(messages.keys()))) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Restore the "cache is empty" priming guard.
Line 4031 only checks has(sessionId), but the safety condition here is stronger than that. If the cache already contains other sessions, getLastSessionLog() can still seed this session with a stale disk snapshot, and later UUID dedup will miss unflushed messages.
Suggested fix
const messagesCache = getSessionMessagesCache()
- if (!messagesCache.has(sessionId)) {
+ if (messagesCache.size === 0) {
messagesCache.set(sessionId, Promise.resolve(new Set(messages.keys())))
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const messagesCache = getSessionMessagesCache() | |
| if (!messagesCache.has(sessionId)) { | |
| messagesCache.set(sessionId, Promise.resolve(new Set(messages.keys()))) | |
| const messagesCache = getSessionMessagesCache() | |
| if (messagesCache.size === 0) { | |
| messagesCache.set(sessionId, Promise.resolve(new Set(messages.keys()))) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/utils/sessionStorage.ts` around lines 4030 - 4032, The session cache
priming in getLastSessionLog()/getSessionMessagesCache() needs the original
“cache is empty” guard restored, not just a per-session has(sessionId) check.
Update the priming logic so it only seeds from disk when the entire messages
cache is empty, preventing stale snapshots from being written into a session
that already has cached entries and avoiding UUID dedup misses for unflushed
messages.
Summary
getSessionMessageswas implemented vialodash memoize, which has no size cap. Each unique sessionId adds aPromise<Set<UUID>>entry that is only cleared by
clearSessionMessagesCache()— and that call is only made frompostCompactCleanup, which fires onceper compaction.
In long-running daemon / swarm sessions that spawn many agents (each with its own transcript), the cache grows unbounded for the
process lifetime. Per-entry
Set<UUID>values are MUCH larger than the file-path strings the adjacentexistingSessionFilescachestores, so the leak is more severe than the one fixed for that cache at
sessionStorage.ts:535.Root Cause
lodash memoizeexposes no size cap. The cache grows for the lifetime of the process unlessclearSessionMessagesCache()is called,and that only happens once per compaction (
postCompactCleanup).The adjacent
existingSessionFilescache atsessionStorage.ts:1314-1337already solves this for file paths:…but
getSessionMessageswas missed when that fix landed. Likely becauselodash memoizelooked "obviously correct" and theper-entry size wasn't obvious until swarm sessions exercised it.
Fix
Mirror the
existingSessionFilespattern: replacelodash memoizewith aMap<UUID, Promise<Set<UUID>>>and evict the oldest entry(FIFO via Map insertion order) when at
MAX_CACHED_SESSION_FILEScapacity. Re-uses the same constant for consistency.The
getLastSessionLogpriming code atsessionStorage.ts:4027is updated to usegetSessionMessagesCache().has/setdirectlyinstead of
getSessionMessages.cache.has/set(which was the lodash-internal.cacheproperty).getSessionMessagesis now exported with a comment noting it's not part of the public API (callers should usedoesMessageExistInSession), but is exported so tests can verify the eviction behavior end-to-end.Test Coverage
7 new tests in
src/utils/__tests__/sessionStorage.test.ts:Cache mechanics (4 tests):
getSessionMessagesCache()returns the same Map instance across calls (identity)clearSessionMessagesCache()empties a populated cacheclearSessionMessagesCache()is a no-op on empty cachegetSessionMessages()dedups concurrent calls for the same sessionId (single in-flight promise)Bounded cache / memory leak fix (3 tests):
MAX_CACHED_ENTRIES(200) after 600 distinct sessionIdsTests use real temp directory +
CLAUDE_CONFIG_DIRenv (nomock.modulepollution), reading real.jsonlfiles for end-to-endcoverage of the eviction logic.
Verification
bun run precheckon Windows 11: 5899/5900 pass (the 1 fail is a pre-existing flakytoolEventObserver > pruneEmittedTurns > prunes Set entries exceeding SET_MAX keeping most recenttest that consistently times out at 5s on Windows runners, unrelated to thischange)
Environment Tested
Summary by CodeRabbit
Bug Fixes
Tests