Skip to content

fix: tenant-scope memory workspace ids so co-located accounts can't cross-read - #93

Merged
saucam merged 6 commits into
mainfrom
fix/memory-tenant-isolation
Jul 3, 2026
Merged

fix: tenant-scope memory workspace ids so co-located accounts can't cross-read#93
saucam merged 6 commits into
mainfrom
fix/memory-tenant-isolation

Conversation

@saucam

@saucam saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Problem (cross-tenant memory disclosure)

The memory episode store scopes every retrieval — recall, timeline, searchSessions, listRecent, ftsSearch, the vector matrix, and file_reads — solely by workspace_id, and workspace_id was derived from the working directory alone (git-common-dir, else path hash).

So two accounts working the same directory on one daemon got the same workspace_id. One tenant's recall() / timeline() then returned the other tenant's episodes verbatim — including tool outputs and file contents. The memory MCP tools (recall/timeline) had no tenant masking at all; only the human session.search RPC masked live-session hits (a stopgap it explicitly flagged as needing a tenant-scoped store).

Fix — fold the tenant into the workspace id

workspaceIdFromPath(workdir, tenant) now mixes account_id + project_id into the hash. Because every episode query already scopes by workspace_id, making the id tenant-specific isolates every path by construction — no per-query WHERE tenant = ? to individually get right and never miss. Same tenant + same repo still collapse to one id, so cross-worktree memory sharing is preserved.

The tenant argument is required, so TypeScript refuses to compile until every derivation site supplies it — the compiler enforces completeness rather than me hand-auditing call sites.

Threaded through:

  • Session derives the id from its auth (which carries the tenant).
  • ClaudeProvider now receives that id instead of re-deriving from the workdir (the re-derivation silently dropped the tenant and would desync the memory MCP binding from where episodes are stored).
  • session.search and the share export/import paths bind the caller's tenant into the id.

AuthContext is structurally a WorkspaceTenant ({account_id, project_id}), so each site passes auth directly — no destructuring boilerplate.

Tests

src/tests/memory.test.ts:

  • Same path + different tenants → different ids; account and project each independently affect the id; same (path, tenant) is stable.
  • End-to-end store+engine: an episode ingested by tenant A is invisible to tenant B's recall / timeline / listRecent / ftsSearch in the same directory, while tenant A still recalls its own.

Full suite green: bun test (735), tsc, biome.

Migration note

Episodes written under the old path-only ids are not visible to the new tenant-scoped ids — a one-time memory reset. Acceptable for the isolation guarantee (documented in the code); memory re-accumulates from the next session.

Addresses the private "memory has no tenant isolation" security advisory. The session.search masking is now belt-and-suspenders (cross-tenant hits no longer share a workspace id to begin with).

Summary by CodeRabbit

  • New Features
    • Added tenant-scoped workspace memory identifiers to keep episode data isolated by account/project.
    • Introduced an automatic, best-effort migration to re-key legacy workspace identifiers on startup.
  • Bug Fixes
    • Prevented cross-tenant workspace ID reuse across session search, export, and import.
    • Ensured provider and memory operations consistently use the same tenant-scoped workspace.
  • Tests
    • Added/expanded tenant-scoped workspace-id fixtures and end-to-end isolation and migration coverage.
  • Documentation
    • Updated release behavior coverage for transcript teardown by adding a new flush() method.

…ross-read

The memory episode store scopes every query (recall, timeline, searchSessions,
listRecent, ftsSearch, vector matrix, file_reads) solely by workspace_id, and
workspace_id was derived from the working directory alone. Two accounts working
the SAME directory on one daemon got the SAME workspace_id, so one tenant's
recall()/timeline() (incl. the memory MCP tools, which had no masking) returned
the other tenant's episodes verbatim — a cross-tenant disclosure.

Fold the tenant (account_id + project_id) into workspaceIdFromPath's hash, so
co-located tenants get distinct ids and every existing query is tenant-scoped by
construction — no per-query filter to individually get right. The tenant arg is
REQUIRED, so the compiler enforces that every derivation site supplies it.

- workspaceIdFromPath(workdir, tenant) mixes the tenant into the id; same
  tenant + same repo still collapse to one id (cross-worktree sharing kept).
- Session derives it from its auth; the ClaudeProvider now receives that id
  instead of re-deriving (which dropped the tenant); session.search and the
  share export/import paths bind the caller's tenant.

Note: episodes written under the old path-only ids are not visible to the new
tenant-scoped ids — a one-time memory reset, acceptable for the isolation fix.

Tests: same-path/different-tenant ids diverge; an episode ingested by tenant A
is invisible to tenant B's recall/timeline/listRecent/ftsSearch in the same dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Workspace IDs now incorporate tenant context across session creation, provider wiring, export/import/search handling, startup migration, and tests. Transcript persistence also gains an explicit flush hook.

Changes

Tenant-aware workspace scoping

Layer / File(s) Summary
Migration inputs and startup re-keying
src/daemon/memory/index.ts, src/daemon/store.ts, src/daemon/server.ts
Re-exports legacy workspace helpers and tenant types, adds a session listing query for migration, and re-keys stored episode workspace IDs during daemon startup.
Session and provider workspace binding
src/daemon/session.ts, src/daemon/providers/claude/index.ts
Session computes a tenant-scoped workspace ID once and passes it into ClaudeProvider, which uses that value for the memory MCP server.
Tenant-scoped session manager paths
src/daemon/session-manager.ts
Session export, import, and workspace search now derive workspace IDs with caller auth context.
Transcript flush and tenant test harness
src/daemon/transcript.ts, src/tests/session-manager-tenant.test.ts
TranscriptStore adds flush(), and new tenant-focused session manager tests cover search and export/import round-trips with isolated storage.
Workspace ID and isolation tests
src/tests/memory.test.ts, src/tests/provider-claude.test.ts
Memory tests cover tenant-aware ID derivation, migration, and isolation, and the provider test helper now supplies an explicit workspace ID.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: tenant-scoped memory workspace IDs to prevent cross-tenant reads.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/memory-tenant-isolation

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.06%. Comparing base (ae00891) to head (285ce10).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #93      +/-   ##
==========================================
+ Coverage   66.86%   73.06%   +6.19%     
==========================================
  Files          65       65              
  Lines       11165    11032     -133     
==========================================
+ Hits         7466     8060     +594     
+ Misses       3699     2972     -727     
Flag Coverage Δ
daemon 73.06% <100.00%> (+6.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/daemon/memory/index.ts 80.00% <100.00%> (ø)
src/daemon/memory/store.ts 90.63% <ø> (+1.44%) ⬆️
src/daemon/providers/claude/index.ts 97.36% <100.00%> (ø)
src/daemon/session-manager.ts 41.63% <100.00%> (+28.89%) ⬆️
src/daemon/session.ts 73.77% <100.00%> (+0.01%) ⬆️
src/daemon/store.ts 75.47% <100.00%> (+0.83%) ⬆️
src/daemon/transcript.ts 93.70% <100.00%> (+0.25%) ⬆️

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/daemon/session.ts (1)

338-340: 🗄️ Data Integrity & Integration | 🔵 Trivial

Tenant-scoped derivation is consistent; flag the one-time memory-visibility break for existing installs.

Centralizing derivation here and reusing this.#workspaceId for the chunker, index scheduler, usage recording, and provider MCP binding keeps read/write scope aligned — correct.

Operationally, since the hash input now includes account_id/project_id, episodes written under the old path-only IDs will no longer resolve after upgrade (recall/timeline/usage will read empty for existing workspaces). Consider a one-time backfill that re-keys existing workspace_id rows to the new tenant-scoped IDs (using each session's stored accountId/projectId), or documenting the expected memory reset in upgrade notes.

🤖 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/daemon/session.ts` around lines 338 - 340, The tenant-scoped workspaceId
change in session.ts means existing path-only memory entries will no longer be
found after upgrade. Update the session initialization flow around
workspaceIdFromPath and the uses of this.#workspaceId to either add a one-time
migration/backfill that re-keys stored workspace_id rows using each session’s
accountId/projectId, or clearly document the expected memory reset for existing
installs in the upgrade notes.
🤖 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.

Nitpick comments:
In `@src/daemon/session.ts`:
- Around line 338-340: The tenant-scoped workspaceId change in session.ts means
existing path-only memory entries will no longer be found after upgrade. Update
the session initialization flow around workspaceIdFromPath and the uses of
this.#workspaceId to either add a one-time migration/backfill that re-keys
stored workspace_id rows using each session’s accountId/projectId, or clearly
document the expected memory reset for existing installs in the upgrade notes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 980636e7-d101-4693-ae7e-7e5229d1018e

📥 Commits

Reviewing files that changed from the base of the PR and between 26b51c8 and da0a394.

📒 Files selected for processing (7)
  • src/daemon/memory/index.ts
  • src/daemon/memory/store.ts
  • src/daemon/providers/claude/index.ts
  • src/daemon/session-manager.ts
  • src/daemon/session.ts
  • src/tests/memory.test.ts
  • src/tests/provider-claude.test.ts

Adds a SessionManager-level test that drives session.search and a
session.create -> export -> import round-trip through handle(), exercising the
three sites that derive a workspace id from the caller's auth. The isolation
property itself is covered in memory.test.ts; this guards the handler wiring
(and satisfies patch coverage on the changed lines).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/tests/session-manager-tenant.test.ts (2)

128-141: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Weak final assertion undermines the test's regression value.

Accepting either "session.import.result" or "response.error" (Line 140) means the test passes even if the tenant-bound workspaceIdFor wiring silently breaks and import always errors out. The comment above acknowledges this tradeoff, but it defeats the stated purpose of "exercising the tenant binding end-to-end (and can't silently regress)" (Line 7).

Assert on the successful path explicitly (or branch and assert meaningfully on both outcomes) so a regression that always fails import is actually caught.

🧪 Suggested tightening
-    // Either a successful import or a well-formed error — both exercised the
-    // tenant-bound workspaceIdFor path; a thrown/undefined response would not.
-    expect(["session.import.result", "response.error"]).toContain(imported.type);
+    expect(imported.type).toBe("session.import.result");
+    // add further assertions on newSessionId / importedEpisodes here
🤖 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/tests/session-manager-tenant.test.ts` around lines 128 - 141, The final
assertion in session-manager-tenant.test.ts is too permissive because it accepts
both success and error, so a broken tenant-bound workspaceIdFor path could still
pass. Update the test around manager.handle for session.import to assert the
successful import result explicitly, or split the assertion so
session.import.result and response.error are checked with different
expectations. Keep the check anchored on the imported response type so this
regression cannot silently pass.

128-137: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

as never cast bypasses type checking on the import payload.

Casting the message object to never (Line 134) suppresses structural verification against the actual session.import request type, so a shape drift (e.g. wrong field name for the bundle) wouldn't be caught at compile time. Prefer casting to the actual request/message union type (or importing/asserting against it) so tsc --noEmit still validates the payload shape.

🤖 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/tests/session-manager-tenant.test.ts` around lines 128 - 137, The `as
never` cast in the `manager.handle(...)` call bypasses compile-time validation
for the `session.import` payload. Replace it with the real request/message type
from the session manager API so the object is checked structurally by
TypeScript, and keep the test aligned with the actual `session.import` shape
used by `handle`.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@src/tests/session-manager-tenant.test.ts`:
- Around line 128-141: The final assertion in session-manager-tenant.test.ts is
too permissive because it accepts both success and error, so a broken
tenant-bound workspaceIdFor path could still pass. Update the test around
manager.handle for session.import to assert the successful import result
explicitly, or split the assertion so session.import.result and response.error
are checked with different expectations. Keep the check anchored on the imported
response type so this regression cannot silently pass.
- Around line 128-137: The `as never` cast in the `manager.handle(...)` call
bypasses compile-time validation for the `session.import` payload. Replace it
with the real request/message type from the session manager API so the object is
checked structurally by TypeScript, and keep the test aligned with the actual
`session.import` shape used by `handle`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8acc0299-266b-4246-8c00-b6ea125b9fdf

📥 Commits

Reviewing files that changed from the base of the PR and between da0a394 and ff2d332.

📒 Files selected for processing (1)
  • src/tests/session-manager-tenant.test.ts

Addresses a CodeRabbit nitpick: the round-trip test accepted either
import.result OR response.error, so a regression that broke the tenant-bound
workspaceIdFor and made import always error would still pass. Use the correct
inline `source` shape so import actually succeeds, and assert on a fresh
newSessionId — a broken binding now fails the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam

saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the CodeRabbit nitpicks:

  • Import assertion (session-manager-tenant.test.ts) — tightened in 3940c68. The test now uses the correct inline source shape so import genuinely succeeds and asserts a fresh newSessionId; a regression that breaks the tenant-bound workspaceIdFor (import always errors) now fails the test instead of passing.
  • One-time memory-visibility reset (session.ts:338) — kept as documented rather than backfilled. A clean re-key of existing workspace_id rows isn't feasible in-store: episodes live in memory.db while the per-session account_id/project_id live in the sessions table in a separate SQLite file (codeoid.db), so the memory store can't join them. The reset is documented in the workspaceIdFromPath doc comment and the PR description.

saucam and others added 2 commits July 3, 2026 11:41
The round-trip test creates real sessions via the manager, which fire-and-forget
meta writes (saveMeta's atomic tmp->rename). In CI those raced the afterEach
rmSync and surfaced as an unhandled ENOENT that failed the runner even though
the tests passed. Add TranscriptStore.flush() (awaits the in-flight append +
meta chains — also useful for graceful shutdown) and call it before cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…startup

Without this, folding the tenant into the workspace id orphaned every episode
written before the upgrade (path-only ids no longer resolve), losing memory on
the first restart after merge. Add a one-time, guarded startup migration that
re-keys existing episodes to their owning session's tenant-scoped id.

- SqliteEpisodeStore.migrateWorkspaceIdsToTenant(sessions, workspaceIdFor):
  re-keys episodes by owning session (precise; isolates tenants that shared a
  directory). Episodes whose session was destroyed are recovered only when the
  old workspace id maps to a single tenant — ambiguous multi-tenant orphans are
  left untouched rather than risk cross-tenant attribution. Guarded by PRAGMA
  user_version (runs once) and idempotent.
- legacyWorkspaceIdFromPath: frozen copy of the old path-only derivation, used
  to recognise/recover orphans.
- Store.listAllSessionsForMigration: unscoped (id, workdir, tenant) feed.
- server.ts runs it once after memory init, best-effort (non-fatal on error).

Tests: precise re-key + idempotency, two tenants sharing a dir stay separate,
orphan recovered only when single-tenant. Note: turn_usage/file_reads aren't
re-keyed (read by session_id / pure cache).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/daemon/store.ts (1)

151-172: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Unbounded full-table scan runs on every startup, not just once.

listAllSessionsForMigration() has no guard of its own — it's called unconditionally at Line 203 of src/daemon/server.ts on every daemon boot with memory enabled, even after the one-time re-key has already completed. For deployments with many historical sessions, this means a full sessions table scan into memory happens on every restart forever, even though the downstream migrateWorkspaceIdsToTenant call is a guarded no-op after the first run.

Consider having the caller check the migration-completion flag (e.g., expose a cheap needsWorkspaceMigration() check on the memory store) before paying for this query, so post-migration restarts skip it entirely.

🤖 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/daemon/store.ts` around lines 151 - 172, The full-table scan in
listAllSessionsForMigration is being executed on every daemon startup even after
the migration has already completed. Add a cheap migration guard in the memory
store, such as a needsWorkspaceMigration() check, and have src/daemon/server.ts
call listAllSessionsForMigration only when that flag says the workspace-id
migration still needs to run. Keep the existing migrateWorkspaceIdsToTenant
flow, but skip the query entirely once the one-time migration is done.
src/daemon/server.ts (1)

198-215: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Startup migration blocks the event loop; confirm acceptable scan cost.

this.#store.listAllSessionsForMigration() and bun:sqlite-backed migrateWorkspaceIdsToTenant are synchronous calls, so this whole block runs before Bun.serve() starts listening — acceptable for a one-time migration, but as noted in store.ts, the full session-table fetch is re-executed on every startup regardless of whether re-keying already happened. For daemons with large sessions tables, this adds a recurring synchronous startup cost even long after migration completes.

The error handling and non-fatal fallback here are solid — this is purely a call-site cost concern tied to the listAllSessionsForMigration() design flagged in store.ts.

🤖 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/daemon/server.ts` around lines 198 - 215, The startup migration in server
initialization still performs a synchronous full scan every boot via
this.#store.listAllSessionsForMigration() before calling
this.#memory.store.migrateWorkspaceIdsToTenant(), which adds recurring startup
cost after the re-key has already succeeded. Update the server.ts migration
block to run only once per deployment or only when needed by adding a durable
guard/state check around the migration call, so the
listAllSessionsForMigration() scan is skipped on later startups unless the
migration has not yet been completed.
🤖 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.

Nitpick comments:
In `@src/daemon/server.ts`:
- Around line 198-215: The startup migration in server initialization still
performs a synchronous full scan every boot via
this.#store.listAllSessionsForMigration() before calling
this.#memory.store.migrateWorkspaceIdsToTenant(), which adds recurring startup
cost after the re-key has already succeeded. Update the server.ts migration
block to run only once per deployment or only when needed by adding a durable
guard/state check around the migration call, so the
listAllSessionsForMigration() scan is skipped on later startups unless the
migration has not yet been completed.

In `@src/daemon/store.ts`:
- Around line 151-172: The full-table scan in listAllSessionsForMigration is
being executed on every daemon startup even after the migration has already
completed. Add a cheap migration guard in the memory store, such as a
needsWorkspaceMigration() check, and have src/daemon/server.ts call
listAllSessionsForMigration only when that flag says the workspace-id migration
still needs to run. Keep the existing migrateWorkspaceIdsToTenant flow, but skip
the query entirely once the one-time migration is done.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f94b33d-7f05-4885-85aa-aa8fb37cbad3

📥 Commits

Reviewing files that changed from the base of the PR and between 7c0e2ac and ee0929e.

📒 Files selected for processing (6)
  • src/daemon/memory/index.ts
  • src/daemon/memory/store.ts
  • src/daemon/server.ts
  • src/daemon/store.ts
  • src/tests/memory.test.ts
  • src/tests/session-manager-tenant.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/tests/session-manager-tenant.test.ts
  • src/daemon/memory/index.ts

Addresses a CodeRabbit nitpick: listAllSessionsForMigration() ran a full
sessions-table scan on every boot even though migrateWorkspaceIdsToTenant is a
no-op after the first run. Expose needsWorkspaceMigration() (a cheap
user_version check) and gate the scan on it in server.ts, so post-migration
restarts skip the read entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam
saucam merged commit f7487bc into main Jul 3, 2026
5 checks passed
saucam added a commit that referenced this pull request Jul 6, 2026
…log (#117)

The 0.2.0 entry only covered the protocol/packages train (#100-#116) and
missed ten PRs that also ship in this release: the untrusted-content
sanitization and cross-tenant memory fixes (#91, #93 — now under a
proper Security heading), the performance run (#94-#99), and the model
catalog work (#78, #79).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant