mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-10 06:23:28 +09:00
feat(encryption): ADR-096 Phase 3 — wire vault into terminal-tools
End-to-end opt-in encryption for the .claude-flow/terminals/store.json
store. Terminal command history routinely carries pasted credentials
(API keys, DB passwords), so this is the highest-stakes of the three
high-tier targets.
Mechanics — same shape as Phase 2 (session-tools) so reviewers can
diff the patterns:
src/mcp-tools/terminal-tools.ts
- loadTerminalStore: readFileSync → readFileMaybeEncrypted; the
magic-byte sniff handles legacy plaintext + post-migration
encrypted in one read path
- saveTerminalStore: passes { encrypt: true } unconditionally;
vault env-gate decides whether bytes are actually encrypted
- Dropped the now-unused readFileSync import
Tests (__tests__/terminal-encryption.test.ts, 7 cases):
encryption disabled (legacy plaintext)
- writes plaintext JSON (first byte '{', not 'R')
- round-trips create → list
encryption enabled (RFE1 wire format)
- on-disk blob starts with the RFE1 magic
- plaintext name is NOT present anywhere in the on-disk bytes —
stricter than the equivalent session test because terminal stores
carry pasted credentials and an encryption regression here is a
direct CVE
- round-trips create → list
migration
- legacy plaintext store written before the gate flipped is still
readable AFTER the gate flips on (sniff path)
mixed: same store, plaintext-then-encrypted
- first save plaintext (gate off), second save encrypted (gate on);
saveTerminalStore rewrites the whole file → on-disk becomes
encrypted bytes containing BOTH sessions. List enumerates both.
Verified:
tsc --noEmit clean (0 errors)
Encryption-track suite (vault + fs-secure + session + terminal):
67/67 passing
Full vitest suite: 1924/1924 passing, 46 skipped, 0 failures
(was 1917; +7 from this commit)
Phase 4 wires the seven memory-initializer DB writes — the sql.js
SQLite blob containing memory entries + 384-dim embeddings. Different
shape (whole-Buffer encryption vs JSON.stringify) so it lands as its
own iteration.
This commit is contained in:
1 parent
149ea30a45
commit
49c8019ed9
2 files changed
+191
-5
No files matched your search
@@ -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<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 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<string, unknown>);
|
||||
}
|
||||
|
||||
describe('terminal-tools encryption-at-rest (ADR-096 Phase 3)', () => {
|
||||
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(), '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<string, { name: string }>;
|
||||
};
|
||||
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<string, unknown>)) 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<string, unknown>)) 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<string, unknown>)) 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<string, unknown>)) 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in new issue
Block a user