feat(encryption): ADR-096 Phase 2 — wire vault into session-tools

End-to-end opt-in encryption for the .claude-flow/sessions/*.json store.
The session writer is the smallest of the three high-tier targets, so it
ships first to bound the blast radius. Terminal stores and the
sql.js memory DB land in follow-up iterations.

Mechanics:

  src/fs-secure.ts
    - writeFileRestricted gains an options object: { encoding, encrypt }
      Legacy positional encoding signature still works, so all 5 existing
      call sites keep compiling unchanged.
    - When encrypt:true AND CLAUDE_FLOW_ENCRYPT_AT_REST is set, payload
      is run through encryptBuffer() before writeFileSync. Otherwise the
      flag is silently ignored — legacy plaintext path runs unchanged.
    - New readFileMaybeEncrypted(path, encoding?) — magic-byte sniff,
      decrypt-if-RFE1, otherwise pass-through. Two-overload signature
      mirrors fs.readFileSync (string default, Buffer with encoding=null).

  src/mcp-tools/session-tools.ts
    - saveSession passes { encrypt: true } unconditionally. The vault
      gate decides whether the bytes actually get encrypted.
    - loadSession + listSessions switch to readFileMaybeEncrypted, so
      a mixed plaintext+encrypted dir enumerates cleanly during the
      migration window.

Tests (__tests__/session-encryption.test.ts, 7 cases):

  encryption disabled
    - writes plaintext JSON (first byte '{', not 'R')
    - round-trips save → restore
  encryption enabled
    - on-disk blob starts with RFE1 magic
    - plaintext name is NOT present anywhere in the on-disk bytes
      (defense-in-depth: confirms encryption actually ran)
    - round-trips save → restore using the same key
  migration
    - a legacy plaintext file written before the gate flipped is
      still readable AFTER the gate flips on (sniff path)
  mixed dir
    - listSessions enumerates plain-1 + encrypted-1 in one call

Verified:
  tsc --noEmit clean (0 errors)
  Vault + fs-secure + session-encryption: 60/60
  Full vitest suite: 1917/1917 passing, 46 skipped, 0 failures
  (was 1910; +7 from the new test file)

Phase 3 wires terminal-tools.saveTerminalStore. Phase 4 wires the
seven memory-initializer DB writes.
This commit is contained in:
Reuven committed 2026-05-03 21:05:26 -04:00
1 parent cb9a9f346c
commit 98aa2560e5
3 files changed
+293 -9

No files matched your search

@@ -0,0 +1,178 @@
/**
* Integration test for ADR-096 Phase 2: session-tools wired to vault.
*
* Exercises the full save → on-disk → load round-trip across both modes
* (plaintext + encrypted) AND the migration path (legacy plaintext file
* still readable after the encryption gate flips on).
*
* The vault primitives themselves are unit-tested in encryption-vault.test.ts.
* This file pins the contract that session-tools honors the env-var gate
* AND that the magic-byte format is what actually lands on disk.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { sessionTools } from '../src/mcp-tools/session-tools.js';
import { MAGIC, isEncryptedBlob } from '../src/encryption/vault.js';
// Per-test working directory so the in-process getProjectCwd() points at
// a clean tree. session-tools uses process.cwd() (via getProjectCwd) for
// the sessions dir.
const SAVED_ENV: Record<string, string | undefined> = {};
function saveEnv(...names: string[]) {
for (const n of names) SAVED_ENV[n] = process.env[n];
}
function restoreEnv() {
for (const [n, v] of Object.entries(SAVED_ENV)) {
if (v === undefined) delete process.env[n];
else process.env[n] = v;
}
}
const sessionSaveTool = sessionTools.find(t => t.name === 'session_save')!;
const sessionRestoreTool = sessionTools.find(t => t.name === 'session_restore')!;
const sessionListTool = sessionTools.find(t => t.name === 'session_list')!;
function pickSessionFile(workdir: string): string {
const dir = join(workdir, '.claude-flow', 'sessions');
// Find the lone JSON file the save tool produces (filename is
// `session-<ts>-<rand>.json`).
const fs = readFileSync; void fs; // (silence unused-import linters)
const { readdirSync } = require('node:fs') as typeof import('node:fs');
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
expect(files.length).toBe(1);
return join(dir, files[0]);
}
async function runSave(name: string) {
return sessionSaveTool.handler({ name } as Record<string, unknown>);
}
async function runRestore(sessionId: string) {
return sessionRestoreTool.handler({ sessionId } as Record<string, unknown>);
}
describe('session-tools encryption-at-rest (ADR-096 Phase 2)', () => {
let workdir: string;
let cwdSpy: ReturnType<typeof vi.spyOn> | null = null;
beforeEach(() => {
saveEnv('CLAUDE_FLOW_ENCRYPT_AT_REST', 'CLAUDE_FLOW_ENCRYPTION_KEY');
workdir = mkdtempSync(join(tmpdir(), 'session-enc-'));
// Pin process.cwd() so session-tools writes into our temp dir
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workdir);
});
afterEach(() => {
cwdSpy?.mockRestore();
rmSync(workdir, { recursive: true, force: true });
restoreEnv();
});
describe('encryption disabled (legacy plaintext)', () => {
beforeEach(() => {
delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST;
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
});
it('writes plaintext JSON to disk', async () => {
const r = (await runSave('audit-test')) as { sessionId: string };
const onDisk = readFileSync(pickSessionFile(workdir));
// First byte is "{" not "R" — definitely not encrypted
expect(onDisk[0]).toBe(0x7b);
expect(isEncryptedBlob(onDisk)).toBe(false);
// Sanity: the JSON parses and the sessionId round-trips
const parsed = JSON.parse(onDisk.toString('utf-8'));
expect(parsed.sessionId).toBe(r.sessionId);
expect(parsed.name).toBe('audit-test');
});
it('round-trips save → restore', async () => {
const saved = (await runSave('rt-plain')) as { sessionId: string };
const restored = (await runRestore(saved.sessionId)) as {
restored?: boolean;
name?: string;
};
expect(restored.restored).toBe(true);
expect(restored.name).toBe('rt-plain');
});
});
describe('encryption enabled (RFE1 wire format)', () => {
beforeEach(() => {
process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1';
process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex');
});
it('writes a blob that starts with the RFE1 magic', async () => {
await runSave('encrypted-test');
const onDisk = readFileSync(pickSessionFile(workdir));
expect(onDisk.subarray(0, 4)).toEqual(MAGIC);
expect(isEncryptedBlob(onDisk)).toBe(true);
});
it('produces a blob that does not contain the plaintext name', async () => {
// Defense-in-depth check: the plaintext name shouldn't appear
// anywhere in the on-disk bytes if encryption actually ran.
const secretName = `top-secret-${randomBytes(8).toString('hex')}`;
await runSave(secretName);
const onDisk = readFileSync(pickSessionFile(workdir));
expect(onDisk.includes(Buffer.from(secretName, 'utf-8'))).toBe(false);
});
it('round-trips save → restore using the same key', async () => {
const saved = (await runSave('rt-encrypted')) as { sessionId: string };
const restored = (await runRestore(saved.sessionId)) as {
restored?: boolean;
name?: string;
};
expect(restored.restored).toBe(true);
expect(restored.name).toBe('rt-encrypted');
});
});
describe('migration: encrypt-on-write only, plaintext stays readable', () => {
it('reads a legacy plaintext file even after encryption is enabled', async () => {
// Step 1: save with encryption OFF → plaintext on disk
delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST;
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
const saved = (await runSave('legacy-plain')) as { sessionId: string };
// Step 2: turn encryption ON for the read
process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1';
process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex');
// Step 3: restore should still find the plaintext file via the
// magic-byte sniff (legacy file → not encrypted → returned as-is)
const restored = (await runRestore(saved.sessionId)) as {
restored?: boolean;
name?: string;
};
expect(restored.restored).toBe(true);
expect(restored.name).toBe('legacy-plain');
});
});
describe('listSessions handles a mixed dir', () => {
it('enumerates plaintext + encrypted sessions in one call', async () => {
// First session: plaintext
delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST;
delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY;
await runSave('plain-1');
// Second session: encrypted
process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1';
process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex');
await runSave('encrypted-1');
const result = (await sessionListTool.handler({} as Record<string, unknown>)) as {
sessions: Array<{ name?: string }>;
};
const names = result.sessions.map(s => s.name).filter(Boolean).sort();
expect(names).toEqual(['encrypted-1', 'plain-1']);
});
});
});
+95 -5
View File
@@ -9,9 +9,22 @@
* 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.
*
* ADR-096 Phase 2: optional opt-in encryption-at-rest. When the caller
* passes `encrypt: true` AND the env-gated vault is enabled, payloads are
* AES-256-GCM-encrypted before hitting disk. Reads use the magic-byte
* sniff so legacy plaintext files keep working unchanged during the
* incremental migration.
*/
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import {
decryptBuffer,
encryptBuffer,
getKey,
isEncryptedBlob,
isEncryptionEnabled,
} from './encryption/vault.js';
/**
* Create a directory tree with mode 0700 (owner-only). No-op if exists.
@@ -21,20 +34,97 @@ export function mkdirRestricted(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 });
}
/**
* Options for writeFileRestricted. Object form so we can grow the API
* without churning every call site.
*/
export interface WriteOptions {
/** Buffer encoding when `data` is a string. Ignored for Buffer payloads. */
encoding?: BufferEncoding;
/**
* If true AND encryption is globally enabled (CLAUDE_FLOW_ENCRYPT_AT_REST),
* encrypt the payload with AES-256-GCM before writing. If encryption is
* NOT enabled, this flag is silently ignored — the legacy plaintext path
* runs unchanged. Default: false.
*/
encrypt?: boolean;
}
/**
* 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.
*
* Two call signatures, both supported (the legacy positional one keeps
* existing call sites working without churn):
*
* writeFileRestricted(path, data) // plaintext, utf-8
* writeFileRestricted(path, data, 'utf-8') // legacy: encoding only
* writeFileRestricted(path, data, { encrypt: true }) // opt-in encryption
*/
export function writeFileRestricted(
path: string,
data: string | Buffer,
encoding: BufferEncoding = 'utf-8',
optsOrEncoding: BufferEncoding | WriteOptions = 'utf-8',
): void {
writeFileSync(path, data, encoding);
const opts: WriteOptions =
typeof optsOrEncoding === 'string'
? { encoding: optsOrEncoding }
: optsOrEncoding;
const encoding = opts.encoding ?? 'utf-8';
let payload: string | Buffer = data;
if (opts.encrypt && isEncryptionEnabled()) {
const plaintext = Buffer.isBuffer(data) ? data : Buffer.from(data, encoding);
payload = encryptBuffer(plaintext, getKey());
}
// For encrypted payloads we always have a Buffer — pass through without an
// encoding so writeFileSync doesn't try to text-decode it.
if (Buffer.isBuffer(payload)) {
writeFileSync(path, payload);
} else {
writeFileSync(path, payload, encoding);
}
try {
chmodSync(path, 0o600);
} catch {
// Windows / FS without POSIX modes — silently skip.
}
}
/**
* Read a file and transparently decrypt if it carries the RFE1 magic.
*
* Returns a string when the caller asks for one (default utf-8). Returns
* a Buffer when `encoding` is null. This matches Node's readFileSync
* shape so the function is a near-drop-in replacement.
*
* Migration semantics:
* - If the file IS encrypted, decrypt and return.
* - If the file is NOT encrypted, return its raw bytes (string-decoded
* under `encoding` if requested).
*
* That means a reader can be migrated *first*, before its writer flips
* `encrypt: true`, without breaking on the legacy plaintext path.
*/
export function readFileMaybeEncrypted(
path: string,
encoding?: BufferEncoding,
): string;
export function readFileMaybeEncrypted(
path: string,
encoding: null,
): Buffer;
export function readFileMaybeEncrypted(
path: string,
encoding: BufferEncoding | null = 'utf-8',
): string | Buffer {
const raw = readFileSync(path);
let plain: Buffer;
if (isEncryptedBlob(raw)) {
plain = decryptBuffer(raw, getKey());
} else {
plain = raw;
}
return encoding === null ? plain : plain.toString(encoding);
}
@@ -7,7 +7,11 @@
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 {
mkdirRestricted,
readFileMaybeEncrypted,
writeFileRestricted,
} from '../fs-secure.js';
import { validateIdentifier, validateText } from './validate-input.js';
// Storage paths
@@ -53,7 +57,10 @@ function loadSession(sessionId: string): SessionRecord | null {
try {
const path = getSessionPath(sessionId);
if (existsSync(path)) {
const data = readFileSync(path, 'utf-8');
// ADR-096 Phase 2: readFileMaybeEncrypted transparently handles both
// legacy plaintext sessions and post-migration encrypted ones via the
// RFE1 magic-byte sniff.
const data = readFileMaybeEncrypted(path, 'utf-8');
return JSON.parse(data);
}
} catch {
@@ -66,7 +73,14 @@ function saveSession(session: SessionRecord): void {
ensureSessionDir();
// audit_1776853149979: session JSON contains memory snapshots and agent
// prompts — restrict to owner read/write.
writeFileRestricted(getSessionPath(session.sessionId), JSON.stringify(session, null, 2));
// ADR-096 Phase 2: opt-in encrypt-at-rest. The encrypt flag is honored
// only when CLAUDE_FLOW_ENCRYPT_AT_REST is set; otherwise the legacy
// plaintext path runs unchanged.
writeFileRestricted(
getSessionPath(session.sessionId),
JSON.stringify(session, null, 2),
{ encrypt: true },
);
}
function listSessions(): SessionRecord[] {
@@ -77,7 +91,9 @@ function listSessions(): SessionRecord[] {
const sessions: SessionRecord[] = [];
for (const file of files) {
try {
const data = readFileSync(join(dir, file), 'utf-8');
// ADR-096 Phase 2: same magic-byte sniff for the listing path so a
// mixed plaintext+encrypted dir still enumerates cleanly.
const data = readFileMaybeEncrypted(join(dir, file), 'utf-8');
sessions.push(JSON.parse(data));
} catch {
// Skip invalid files