Skip to content

[Bug]: Multi-write operations in memory index lack transaction wrappers #10324

Description

@coygeek

CVSS Assessment

Metric Value
Score 6.8 / 10.0
Severity Medium
Vector CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L

CVSS v3.1 Calculator

Summary

Multiple methods in the memory index manager perform database writes across four tables (chunks, chunks_vec, chunks_fts, files) without transaction wrappers. If any operation fails midway due to disk errors, process crashes, or resource exhaustion, the SQLite database will be left in an inconsistent state with orphaned records, missing embeddings, or mismatched indexes.

Affected Code

Location 1: indexFile method

File: src/memory/manager.ts:2257-2354

private async indexFile(
  entry: MemoryFileEntry | SessionFileEntry,
  options: { source: MemorySource; content?: string },
) {
  // ... embedding logic ...

  // DELETE operations without transaction
  if (vectorReady) {
    try {
      this.db
        .prepare(
          `DELETE FROM ${VECTOR_TABLE} WHERE id IN (SELECT id FROM chunks WHERE path = ? AND source = ?)`,
        )
        .run(entry.path, options.source);
    } catch {}
  }
  if (this.fts.enabled && this.fts.available) {
    try {
      this.db
        .prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`)
        .run(entry.path, options.source, this.provider.model);
    } catch {}
  }
  this.db
    .prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
    .run(entry.path, options.source);

  // INSERT operations in loop without transaction
  for (let i = 0; i < chunks.length; i++) {
    // INSERT into chunks, VECTOR_TABLE, FTS_TABLE
  }

  // Final INSERT into files
  this.db.prepare(`INSERT INTO files ...`).run(...);
}

Location 2: syncMemoryFiles stale cleanup

File: src/memory/manager.ts:1117-1137

// Inside syncMemoryFiles method - stale entry cleanup
for (const stale of staleRows) {
  if (activePaths.has(stale.path)) continue;
  this.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(stale.path, "memory");
  try {
    this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id IN ...`).run(stale.path, "memory");
  } catch {}
  this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory");
  if (this.fts.enabled && this.fts.available) {
    try {
      this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ? AND model = ?`).run(...);
    } catch {}
  }
}

Location 3: syncSessionFiles stale cleanup

File: src/memory/manager.ts:1214-1238

Same pattern as Location 2, but with source = "sessions".

Location 4: Standalone syncMemoryFiles function

File: src/memory/sync-memory-files.ts:81-101

for (const stale of staleRows) {
  if (activePaths.has(stale.path)) continue;
  params.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(stale.path, "memory");
  try {
    params.db.prepare(`DELETE FROM ${params.vectorTable} WHERE id IN ...`).run(stale.path, "memory");
  } catch {}
  params.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "memory");
  if (params.ftsEnabled && params.ftsAvailable) {
    try {
      params.db.prepare(`DELETE FROM ${params.ftsTable} WHERE path = ? AND source = ? AND model = ?`).run(...);
    } catch {}
  }
}

Location 5: Standalone syncSessionFiles function

File: src/memory/sync-session-files.ts:106-130

for (const stale of staleRows) {
  if (activePaths.has(stale.path)) continue;
  params.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(stale.path, "sessions");
  try {
    params.db.prepare(`DELETE FROM ${params.vectorTable} WHERE id IN ...`).run(stale.path, "sessions");
  } catch {}
  params.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`).run(stale.path, "sessions");
  if (params.ftsEnabled && params.ftsAvailable) {
    try {
      params.db.prepare(`DELETE FROM ${params.ftsTable} WHERE path = ? AND source = ? AND model = ?`).run(...);
    } catch {}
  }
}

Attack Surface

How is this reached?

  • Local

Authentication required?

  • None

Entry point: Memory sync operations triggered by file changes, search queries, or interval timers via MemoryIndexManager.sync().

Exploit Conditions

Complexity: [x] Low
User interaction: [x] None
Prerequisites: Process crash, disk full, or OOM during memory indexing operation.

Impact Assessment

Scope: [x] Unchanged

Impact Type Level Description
Confidentiality None No data exposure
Integrity High Database left in inconsistent state: chunks deleted but files table not updated, embeddings missing from vector table, FTS index out of sync with chunks table
Availability Low Memory search returns incomplete or incorrect results until next full reindex

Steps to Reproduce

  1. Start the memory indexer with a large workspace containing many markdown files
  2. Trigger a sync operation (e.g., memory sync --force)
  3. Kill the process mid-operation (Ctrl+C or kill -9)
  4. Restart and observe inconsistent state:
    • SELECT COUNT(*) FROM files vs SELECT COUNT(DISTINCT path) FROM chunks may differ
    • Vector table may have entries for chunks that no longer exist
    • FTS table may be out of sync with chunks table

Recommended Fix

Wrap the multi-write operations in transactions. The codebase already has a working pattern at seedEmbeddingCache (lines 711-759):

// Example for indexFile method
private async indexFile(
  entry: MemoryFileEntry | SessionFileEntry,
  options: { source: MemorySource; content?: string },
) {
  // ... embedding logic unchanged ...

  const now = Date.now();

  try {
    this.db.exec("BEGIN");

    // All DELETE operations
    if (vectorReady) {
      try {
        this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id IN ...`).run(...);
      } catch {}
    }
    // ... other deletes ...

    // All INSERT operations in loop
    for (let i = 0; i < chunks.length; i++) {
      // ... inserts ...
    }

    // Final files INSERT
    this.db.prepare(`INSERT INTO files ...`).run(...);

    this.db.exec("COMMIT");
  } catch (err) {
    try {
      this.db.exec("ROLLBACK");
    } catch {}
    throw err;
  }
}

Apply the same pattern to all 5 affected locations.

References

  • CWE: CWE-460 - Improper Cleanup on Thrown Exception

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstaleMarked as stale due to inactivity

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions