mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-10 06:23:28 +09:00
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:
1 parent
fb256ac596
commit
de96b0eed4
4 files changed
+68
-20
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user