diff --git a/v3/@claude-flow/cli/__tests__/terminal-encryption.test.ts b/v3/@claude-flow/cli/__tests__/terminal-encryption.test.ts new file mode 100644 index 0000000000..692c7f5f8f --- /dev/null +++ b/v3/@claude-flow/cli/__tests__/terminal-encryption.test.ts @@ -0,0 +1,172 @@ +/** + * Integration test for ADR-096 Phase 3: terminal-tools wired to vault. + * + * Mirrors session-encryption.test.ts structure: full save → on-disk → + * load round-trip across plaintext + encrypted modes plus the migration + * path (legacy plaintext readable after the gate flips on). + * + * Terminal stores are higher-stakes than session JSON because the command + * history routinely contains pasted shell commands with credentials — + * the on-disk-doesn't-leak-the-secret assertion is therefore stricter + * than the equivalent session test (we plant a known credential string + * and assert it never appears in the encrypted bytes). + */ + +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 { terminalTools } from '../src/mcp-tools/terminal-tools.js'; +import { MAGIC, isEncryptedBlob } from '../src/encryption/vault.js'; + +const SAVED_ENV: Record = {}; +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 terminalCreateTool = terminalTools.find(t => t.name === 'terminal_create')!; +const terminalListTool = terminalTools.find(t => t.name === 'terminal_list')!; + +function getStoreFile(workdir: string): string { + return join(workdir, '.claude-flow', 'terminals', 'store.json'); +} + +async function runCreate(name: string) { + return terminalCreateTool.handler({ name } as Record); +} + +describe('terminal-tools encryption-at-rest (ADR-096 Phase 3)', () => { + let workdir: string; + let cwdSpy: ReturnType | null = null; + + beforeEach(() => { + saveEnv('CLAUDE_FLOW_ENCRYPT_AT_REST', 'CLAUDE_FLOW_ENCRYPTION_KEY'); + workdir = mkdtempSync(join(tmpdir(), 'term-enc-')); + 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 () => { + await runCreate('legacy-term'); + const onDisk = readFileSync(getStoreFile(workdir)); + // First byte is "{" not "R" — plaintext JSON + expect(onDisk[0]).toBe(0x7b); + expect(isEncryptedBlob(onDisk)).toBe(false); + const parsed = JSON.parse(onDisk.toString('utf-8')) as { + sessions: Record; + }; + const names = Object.values(parsed.sessions).map(s => s.name); + expect(names).toContain('legacy-term'); + }); + + it('round-trips create → list', async () => { + await runCreate('rt-plain'); + const result = (await terminalListTool.handler({} as Record)) as { + sessions?: Array<{ name?: string }>; + }; + const names = (result.sessions ?? []).map(s => s?.name).filter(Boolean); + expect(names).toContain('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 runCreate('encrypted-term'); + const onDisk = readFileSync(getStoreFile(workdir)); + expect(onDisk.subarray(0, 4)).toEqual(MAGIC); + expect(isEncryptedBlob(onDisk)).toBe(true); + }); + + it('does not leak the plaintext name into the on-disk bytes', async () => { + // Defense-in-depth: terminal stores carry pasted credentials in the + // history field. If encryption ran, NO part of the plaintext should + // appear in the on-disk file. + const secretName = `creds-${randomBytes(8).toString('hex')}`; + await runCreate(secretName); + const onDisk = readFileSync(getStoreFile(workdir)); + expect(onDisk.includes(Buffer.from(secretName, 'utf-8'))).toBe(false); + }); + + it('round-trips create → list using the same key', async () => { + await runCreate('rt-enc'); + const result = (await terminalListTool.handler({} as Record)) as { + sessions?: Array<{ name?: string }>; + }; + const names = (result.sessions ?? []).map(s => s?.name).filter(Boolean); + expect(names).toContain('rt-enc'); + }); + }); + + describe('migration: legacy plaintext readable after gate flips on', () => { + it('reads a legacy plaintext store after encryption is enabled', async () => { + // Step 1: write plaintext (gate off) + delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST; + delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY; + await runCreate('legacy-readable'); + + // Step 2: flip 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: list should still find the legacy session via the + // magic-byte sniff (legacy file → not encrypted → returned as-is) + const result = (await terminalListTool.handler({} as Record)) as { + sessions?: Array<{ name?: string }>; + }; + const names = (result.sessions ?? []).map(s => s?.name).filter(Boolean); + expect(names).toContain('legacy-readable'); + }); + }); + + describe('mixed: same store, plaintext write then encrypted overwrite', () => { + it('rewrites the file as encrypted on the next write after gate flips on', async () => { + // Step 1: legacy plaintext write + delete process.env.CLAUDE_FLOW_ENCRYPT_AT_REST; + delete process.env.CLAUDE_FLOW_ENCRYPTION_KEY; + await runCreate('plain-first'); + expect(isEncryptedBlob(readFileSync(getStoreFile(workdir)))).toBe(false); + + // Step 2: enable encryption and add a second session — saveTerminalStore + // rewrites the WHOLE file, so the result is encrypted bytes that + // contain both sessions. + process.env.CLAUDE_FLOW_ENCRYPT_AT_REST = '1'; + process.env.CLAUDE_FLOW_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + await runCreate('encrypted-second'); + + const onDisk = readFileSync(getStoreFile(workdir)); + expect(isEncryptedBlob(onDisk)).toBe(true); + + // Both sessions still visible via list (decrypts on read) + const result = (await terminalListTool.handler({} as Record)) as { + sessions?: Array<{ name?: string }>; + }; + const names = (result.sessions ?? []).map(s => s?.name).filter(Boolean); + expect(names).toContain('plain-first'); + expect(names).toContain('encrypted-second'); + }); + }); +}); diff --git a/v3/@claude-flow/cli/src/mcp-tools/terminal-tools.ts b/v3/@claude-flow/cli/src/mcp-tools/terminal-tools.ts index af5c90608c..782f4a25d9 100644 --- a/v3/@claude-flow/cli/src/mcp-tools/terminal-tools.ts +++ b/v3/@claude-flow/cli/src/mcp-tools/terminal-tools.ts @@ -5,8 +5,12 @@ */ import { type MCPTool, getProjectCwd } from './types.js'; -import { existsSync, readFileSync } from 'node:fs'; -import { mkdirRestricted, writeFileRestricted } from '../fs-secure.js'; +import { existsSync } from 'node:fs'; +import { + mkdirRestricted, + readFileMaybeEncrypted, + 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'; @@ -51,7 +55,10 @@ function loadTerminalStore(): TerminalStore { try { const path = getTerminalPath(); if (existsSync(path)) { - return JSON.parse(readFileSync(path, 'utf-8')); + // ADR-096 Phase 3: readFileMaybeEncrypted handles both legacy + // plaintext stores and post-migration encrypted ones via the RFE1 + // magic-byte sniff. + return JSON.parse(readFileMaybeEncrypted(path, 'utf-8')); } } catch { // Return empty store @@ -62,8 +69,15 @@ function loadTerminalStore(): TerminalStore { function saveTerminalStore(store: TerminalStore): void { ensureTerminalDir(); // audit_1776853149979: terminal command history can contain credentials - // pasted into commands; restrict to owner read/write. - writeFileRestricted(getTerminalPath(), JSON.stringify(store, null, 2)); + // pasted into commands; restrict to owner read/write (mode 0600). + // ADR-096 Phase 3: opt-in AES-256-GCM encrypt-at-rest. Honored only + // when CLAUDE_FLOW_ENCRYPT_AT_REST is set; otherwise legacy plaintext + // path runs unchanged. + writeFileRestricted( + getTerminalPath(), + JSON.stringify(store, null, 2), + { encrypt: true }, + ); } export const terminalTools: MCPTool[] = [