From 96975f3840ba0dc64327a306e4044e9694858767 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 10:15:19 +0800 Subject: [PATCH 1/5] fix(session-persistence): create SQLite databases owner-only --- docs/config-catalog.md | 6 +-- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 17 +++++-- .../tests/sqlite.spec.ts | 50 ++++++++++++++++++- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..9474a4877b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -603,8 +603,8 @@ Requires: `sessions` export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests); a file path is created (with parent - * dirs) on construction. + * opens an in-process database (tests). Missing directories and the database + * are created with owner-only permissions; existing path modes are preserved. */ path: string /** @@ -627,7 +627,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:48`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1411e177be..1dd480bc38 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 7d23292e9d..9f0b7cb7c4 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -8,7 +8,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { DatabaseSync } from 'node:sqlite' -import { mkdir } from 'node:fs/promises' +import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, PersistenceCoordinator, @@ -34,12 +34,22 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } +/** Create a missing database owner-only while preserving an existing file's mode. */ +async function createDatabaseFile(path: string): Promise { + try { + const handle = await open(path, 'wx', 0o600) + await handle.close() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } +} + /** Plugin configuration. */ export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests); a file path is created (with parent - * dirs) on construction. + * opens an in-process database (tests). Missing directories and the database + * are created with owner-only permissions; existing path modes are preserved. */ path: string /** @@ -87,6 +97,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers if (path !== ':memory:') { const abs = resolve(path) await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) + await createDatabaseFile(abs) this.db = openDatabase(abs, journalMode) } else { this.db = openDatabase(path, journalMode) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index bae79b0a53..3606afce67 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' @@ -348,6 +348,52 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('creates a new database and WAL sidecars owner-only without changing an existing directory mode', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const dir = dirname(path) + await chmod(dir, 0o755) + + const b = await backend(path) + await b.ctx.sessionPersistence.list() + + expect((await stat(dir)).mode & 0o777).toBe(0o755) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600) + await b.dispose() + }) + + it('preserves the mode of an existing database file', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + await writeFile(path, '', { mode: 0o644 }) + await chmod(path, 0o644) + + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' }) + await ctx.sessionPersistence.list() + + expect((await stat(path)).mode & 0o777).toBe(0o644) + await fiber.dispose() + }) + + it('surfaces database pre-creation errors other than an existing file', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const blocked = join(dirname(path), 'blocked') + await mkdir(blocked, { mode: 0o500 }) + const b = await backend(join(blocked, 'sessions.db')) + + try { + await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'EACCES' }) + await b.dispose() + } finally { + await chmod(blocked, 0o700) + } + }) + it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') From ac893f97bbbc3727beeab4034d689464dc4f2018 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 10:47:32 +0800 Subject: [PATCH 2/5] fix(session-persistence): address permission review --- docs/config-catalog.md | 4 +++- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 8 +++++++- .../tests/sqlite.spec.ts | 17 +++++------------ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9474a4877b..49607eb494 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -605,6 +605,8 @@ export interface Config { * Filesystem path to the SQLite database file. The special value `:memory:` * opens an in-process database (tests). Missing directories and the database * are created with owner-only permissions; existing path modes are preserved. + * Parent directories writable by another principal are outside the backend's + * database-integrity boundary. */ path: string /** @@ -627,7 +629,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:48`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:52`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1dd480bc38..b5f85bd1a8 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. This default prevents incidental exposure through the process umask; it does not protect database integrity when another principal can modify entries in an existing parent directory. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 9f0b7cb7c4..45c05f750b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -34,7 +34,11 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } -/** Create a missing database owner-only while preserving an existing file's mode. */ +/** + * Create a missing database owner-only while preserving an existing file's + * mode. `DatabaseSync` cannot adopt this handle, so a parent directory writable + * by another principal is outside the backend's database-integrity boundary. + */ async function createDatabaseFile(path: string): Promise { try { const handle = await open(path, 'wx', 0o600) @@ -50,6 +54,8 @@ export interface Config { * Filesystem path to the SQLite database file. The special value `:memory:` * opens an in-process database (tests). Missing directories and the database * are created with owner-only permissions; existing path modes are preserved. + * Parent directories writable by another principal are outside the backend's + * database-integrity boundary. */ path: string /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3606afce67..50d761a39d 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -379,19 +379,12 @@ describe('SessionPersistenceSqlite: edge cases', () => { await fiber.dispose() }) - it('surfaces database pre-creation errors other than an existing file', async () => { - if (process.platform === 'win32') return + it('surfaces database pre-creation errors independently of process privileges', async () => { const path = await freshDbPath() - const blocked = join(dirname(path), 'blocked') - await mkdir(blocked, { mode: 0o500 }) - const b = await backend(join(blocked, 'sessions.db')) + const b = await backend(`${path}\0`) - try { - await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'EACCES' }) - await b.dispose() - } finally { - await chmod(blocked, 0o700) - } + await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + await b.dispose() }) it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { From 85fc58943e34a1d207cde9e2343ba80b9b5afb72 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:37:41 +0800 Subject: [PATCH 3/5] docs(session-persistence): tighten SQLite permission prose --- docs/config-catalog.md | 11 ++++++----- .../session-persistence-sqlite/README.md | 4 +++- .../session-persistence-sqlite/src/index.ts | 16 +++++++++------- .../tests/sqlite.spec.ts | 4 ++-- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a442b5a197..6069263b8e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -656,10 +656,11 @@ Requires: `sessions` export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). Missing directories and the database - * are created with owner-only permissions; existing path modes are preserved. - * Parent directories writable by another principal are outside the backend's - * database-integrity boundary. + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing database + * fail initialization. The backend does not protect integrity when another + * principal can replace the database entry in its parent directory. */ path: string /** @@ -682,7 +683,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:53`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:54`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 17713dda8f..602bd4e33f 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -10,7 +10,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. This default prevents incidental exposure through the process umask; it does not protect database integrity when another principal can modify entries in an existing parent directory. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. + +On filesystems with POSIX modes, the backend creates missing directories as `0700` and exclusively creates a missing database as `0600` before SQLite opens it. New WAL sidecars receive the database's owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through the process umask, but do not protect database integrity when another principal can replace the database entry in its parent directory. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4203f38a87..3515309f9a 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -36,9 +36,10 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { } /** - * Create a missing database owner-only while preserving an existing file's - * mode. `DatabaseSync` cannot adopt this handle, so a parent directory writable - * by another principal is outside the backend's database-integrity boundary. + * Exclusively create a missing database file with owner-only permissions. + * Existing files retain their modes, and errors other than `EEXIST` propagate. + * `DatabaseSync` reopens by path, so this does not protect integrity when + * another principal can replace the database entry in its parent directory. */ async function createDatabaseFile(path: string): Promise { try { @@ -53,10 +54,11 @@ async function createDatabaseFile(path: string): Promise { export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). Missing directories and the database - * are created with owner-only permissions; existing path modes are preserved. - * Parent directories writable by another principal are outside the backend's - * database-integrity boundary. + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing database + * fail initialization. The backend does not protect integrity when another + * principal can replace the database entry in its parent directory. */ path: string /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a07bcb7913..4a6b141e04 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -390,7 +390,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { - it('creates a new database and WAL sidecars owner-only without changing an existing directory mode', async () => { + it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => { if (process.platform === 'win32') return const path = await freshDbPath() const dir = dirname(path) @@ -421,7 +421,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { await fiber.dispose() }) - it('surfaces database pre-creation errors independently of process privileges', async () => { + it('surfaces an invalid database path during pre-creation', async () => { const path = await freshDbPath() const b = await backend(`${path}\0`) From 176973cb7b618a82eec4d24f7497352ea5da4acc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:51:22 +0800 Subject: [PATCH 4/5] docs(session-persistence): clarify SQLite permission limits --- docs/config-catalog.md | 7 ++++--- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 10 ++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6069263b8e..05fa3eab67 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -659,8 +659,9 @@ export interface Config { * opens an in-process database (tests). On filesystems with POSIX modes, * missing directories and databases are created owner-only; existing path * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect integrity when another - * principal can replace the database entry in its parent directory. + * fail initialization. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. */ path: string /** @@ -683,7 +684,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:54`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:55`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 602bd4e33f..e9066d3748 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -12,7 +12,7 @@ Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. -On filesystems with POSIX modes, the backend creates missing directories as `0700` and exclusively creates a missing database as `0600` before SQLite opens it. New WAL sidecars receive the database's owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through the process umask, but do not protect database integrity when another principal can replace the database entry in its parent directory. +On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 3515309f9a..4661b41309 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -38,8 +38,9 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. - * `DatabaseSync` reopens by path, so this does not protect integrity when - * another principal can replace the database entry in its parent directory. + * `DatabaseSync` reopens by path, so this does not protect confidentiality or + * integrity when another principal can replace the database entry in its parent + * directory. */ async function createDatabaseFile(path: string): Promise { try { @@ -57,8 +58,9 @@ export interface Config { * opens an in-process database (tests). On filesystems with POSIX modes, * missing directories and databases are created owner-only; existing path * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect integrity when another - * principal can replace the database entry in its parent directory. + * fail initialization. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. */ path: string /** From ddd3370477fbf420364c9369da4c88c10c0c9786 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:12:16 +0800 Subject: [PATCH 5/5] test(session-persistence): pin rollback journal permissions --- .../session-persistence-sqlite/README.md | 2 +- .../tests/sqlite.spec.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index e9066d3748..11fef96dad 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -12,7 +12,7 @@ Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. -On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. +On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 4a6b141e04..f26edfa54d 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -406,6 +406,22 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b.dispose() }) + it('creates a persistent rollback journal with owner-only mode', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'persist' }) + const m = meta('persist-permissions') + + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600) + await fiber.dispose() + }) + it('preserves the mode of an existing database file', async () => { if (process.platform === 'win32') return const path = await freshDbPath()