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.