fix(security): restrict file mode on session/memory/terminal stores (audit_1776853149979)

Defense-in-depth before encryption-at-rest. The audit flagged plaintext
storage of session, memory, and terminal data — files were previously
written with the process umask, which on most macOS/Linux setups leaves
them mode 0644 (world-readable). They contain conversation snapshots,
agent prompts, embeddings, and pasted shell command history that may
include credentials.

Added src/fs-secure.ts with two helpers:
  writeFileRestricted(path, data, encoding?) — write then chmod 0600
  mkdirRestricted(path) — recursive mkdir with mode 0700
Both no-op the chmod silently on Windows (POSIX modes don't apply).

Wired through:
  mcp-tools/session-tools.ts — saveSession + ensureSessionDir; also the
    memory/tasks/agents subdirs that session_restore writes
  mcp-tools/terminal-tools.ts — saveTerminalStore + ensureTerminalDir
  memory/memory-initializer.ts — all 7 dbPath writes for the sql.js
    SQLite database (memory entries + embeddings)

Encryption-at-rest is the next, larger step (separate ADR). This change
is the cheap, immediate win — every existing reader of these paths
already has the file open via the same process, so there are no
backwards-compat concerns.

Verified:
  tsc --noEmit clean (0 errors)
  Three converted writers, one new helper file, no other call sites
  changed.
This commit is contained in:
Reuven committed 2026-05-03 20:27:07 -04:00
1 parent fb256ac596
commit de96b0eed4
4 files changed
+68 -20

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
/**
* Restricted-permission file helpers.
*
* audit_1776853149979: session/memory/terminal stores were written with the
* process umask, which on most macOS/Linux setups leaves them world-readable
* (mode 0644). They contain conversation snapshots, agent prompts, and
* terminal command history — anyone else on the host can read them.
*
* These helpers write atomically and force mode 0600 (files) / 0700 (dirs).
* chmod fails silently on Windows, where POSIX modes don't apply — that's
* fine, the OS-level ACL surface there is different.
*/
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
/**
* Create a directory tree with mode 0700 (owner-only). No-op if exists.
* Uses recursive: true so missing parents are created with the same mode.
*/
export function mkdirRestricted(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 });
}
/**
* Write a file and tighten its permissions to mode 0600 (owner read/write).
* Equivalent to writeFileSync followed by chmod, but emphasizes intent —
* any subsequent reader auditing the codebase can grep for this name.
*/
export function writeFileRestricted(
path: string,
data: string | Buffer,
encoding: BufferEncoding = 'utf-8',
): void {
writeFileSync(path, data, encoding);
try {
chmodSync(path, 0o600);
} catch {
// Windows / FS without POSIX modes — silently skip.
}
}
@@ -4,9 +4,10 @@
* Tool definitions for session management with file persistence.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { type MCPTool, getProjectCwd } from './types.js';
import { mkdirRestricted, writeFileRestricted } from '../fs-secure.js';
import { validateIdentifier, validateText } from './validate-input.js';
// Storage paths
@@ -44,7 +45,7 @@ function getSessionPath(sessionId: string): string {
function ensureSessionDir(): void {
const dir = getSessionDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
mkdirRestricted(dir);
}
}
@@ -63,7 +64,9 @@ function loadSession(sessionId: string): SessionRecord | null {
function saveSession(session: SessionRecord): void {
ensureSessionDir();
writeFileSync(getSessionPath(session.sessionId), JSON.stringify(session, null, 2), 'utf-8');
// audit_1776853149979: session JSON contains memory snapshots and agent
// prompts — restrict to owner read/write.
writeFileRestricted(getSessionPath(session.sessionId), JSON.stringify(session, null, 2));
}
function listSessions(): SessionRecord[] {
@@ -229,11 +232,12 @@ export const sessionTools: MCPTool[] = [
}
if (session) {
// Restore data to respective stores (legacy JSON for backward compat)
// Restore data to respective stores (legacy JSON for backward compat).
// audit_1776853149979: tighten perms on the restored stores too.
if (session.data?.memory) {
const memoryDir = join(getProjectCwd(), STORAGE_DIR, 'memory');
if (!existsSync(memoryDir)) mkdirSync(memoryDir, { recursive: true });
writeFileSync(join(memoryDir, 'store.json'), JSON.stringify(session.data.memory, null, 2), 'utf-8');
if (!existsSync(memoryDir)) mkdirRestricted(memoryDir);
writeFileRestricted(join(memoryDir, 'store.json'), JSON.stringify(session.data.memory, null, 2));
// Also populate active sql.js SQLite database so memory-tools can find entries
try {
@@ -259,13 +263,13 @@ export const sessionTools: MCPTool[] = [
}
if (session.data?.tasks) {
const taskDir = join(getProjectCwd(), STORAGE_DIR, 'tasks');
if (!existsSync(taskDir)) mkdirSync(taskDir, { recursive: true });
writeFileSync(join(taskDir, 'store.json'), JSON.stringify(session.data.tasks, null, 2), 'utf-8');
if (!existsSync(taskDir)) mkdirRestricted(taskDir);
writeFileRestricted(join(taskDir, 'store.json'), JSON.stringify(session.data.tasks, null, 2));
}
if (session.data?.agents) {
const agentDir = join(getProjectCwd(), STORAGE_DIR, 'agents');
if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
writeFileSync(join(agentDir, 'store.json'), JSON.stringify(session.data.agents, null, 2), 'utf-8');
if (!existsSync(agentDir)) mkdirRestricted(agentDir);
writeFileRestricted(join(agentDir, 'store.json'), JSON.stringify(session.data.agents, null, 2));
}
return {
@@ -5,7 +5,8 @@
*/
import { type MCPTool, getProjectCwd } from './types.js';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { mkdirRestricted, writeFileRestricted } from '../fs-secure.js';
import { validateEnv, validateIdentifier, validatePath, validateText } from './validate-input.js';
import { join } from 'node:path';
import { execSync } from 'node:child_process';
@@ -42,7 +43,7 @@ function getTerminalPath(): string {
function ensureTerminalDir(): void {
const dir = getTerminalDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
mkdirRestricted(dir);
}
}
@@ -60,7 +61,9 @@ function loadTerminalStore(): TerminalStore {
function saveTerminalStore(store: TerminalStore): void {
ensureTerminalDir();
writeFileSync(getTerminalPath(), JSON.stringify(store, null, 2), 'utf-8');
// audit_1776853149979: terminal command history can contain credentials
// pasted into commands; restrict to owner read/write.
writeFileRestricted(getTerminalPath(), JSON.stringify(store, null, 2));
}
export const terminalTools: MCPTool[] = [
@@ -11,6 +11,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeFileRestricted } from '../fs-secure.js';
// ADR-053: Lazy import of AgentDB v3 bridge
let _bridge: typeof import('./memory-bridge.js') | null | undefined;
@@ -1021,7 +1022,7 @@ export async function ensureSchemaColumns(dbPath: string): Promise<{
if (modified) {
// Save updated database
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
writeFileRestricted(dbPath, Buffer.from(data));
}
db.close();
@@ -1237,7 +1238,7 @@ export async function initializeMemoryDatabase(options: {
// Save to file
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
writeFileRestricted(dbPath, buffer);
// Close database
db.close();
@@ -1308,7 +1309,7 @@ export async function initializeMemoryDatabase(options: {
sqliteHeader[26] = 0x20; // min embedded payload
sqliteHeader[27] = 0x20; // leaf payload
fs.writeFileSync(dbPath, sqliteHeader);
writeFileRestricted(dbPath, sqliteHeader);
// ADR-053: Activate ControllerRegistry even on fallback path
const controllerResult = await activateControllerRegistry(dbPath, verbose);
@@ -2012,7 +2013,7 @@ export async function verifyMemoryInit(dbPath: string, options?: {
// Save changes
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
writeFileRestricted(dbPath, Buffer.from(data));
db.close();
const passed = tests.filter(t => t.passed).length;
@@ -2152,7 +2153,7 @@ export async function storeEntry(options: {
// Save
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
writeFileRestricted(dbPath, Buffer.from(data));
db.close();
// Add to HNSW index for faster future searches
@@ -2616,7 +2617,7 @@ export async function getEntry(options: {
// Save updated database
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
writeFileRestricted(dbPath, Buffer.from(data));
db.close();
@@ -2774,7 +2775,7 @@ export async function deleteEntry(options: {
// Save updated database
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
writeFileRestricted(dbPath, Buffer.from(data));
db.close();