diff --git a/packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.runner.ts b/packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.runner.test.ts similarity index 100% rename from packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.runner.ts rename to packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.runner.test.ts diff --git a/packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts b/packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts index ad1ccc3b3..c73ba0c69 100644 --- a/packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts +++ b/packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts @@ -9,7 +9,7 @@ import { describe, test, expect } from 'bun:test' import { resolve, relative } from 'path' const PROJECT_ROOT = resolve(__dirname, '..', '..', '..', '..', '..') -const RUNNER_ABS = resolve(__dirname, 'ExecuteTool.runner.ts') +const RUNNER_ABS = resolve(__dirname, 'ExecuteTool.runner.test.ts') const RUNNER_REL = './' + relative(PROJECT_ROOT, RUNNER_ABS).replace(/\\/g, '/') describe('ExecuteTool', () => { diff --git a/src/services/api/openai/__tests__/streamAdapter.test.ts b/src/services/api/openai/__tests__/streamAdapter.test.ts index d6e86b52f..3d2763673 100644 --- a/src/services/api/openai/__tests__/streamAdapter.test.ts +++ b/src/services/api/openai/__tests__/streamAdapter.test.ts @@ -407,7 +407,8 @@ describe('prompt caching support', () => { const msgStart = events.find(e => e.type === 'message_start') as any expect(msgStart.message.usage.cache_read_input_tokens).toBe(800) - expect(msgStart.message.usage.input_tokens).toBe(1000) + // Anthropic convention: input_tokens = non-cached only (prompt_tokens - cached) + expect(msgStart.message.usage.input_tokens).toBe(200) }) test('defaults cache_read_input_tokens to 0 when no cached_tokens', async () => { diff --git a/src/services/localVault/store.ts b/src/services/localVault/store.ts new file mode 100644 index 000000000..5c9a40963 --- /dev/null +++ b/src/services/localVault/store.ts @@ -0,0 +1,467 @@ +/** + * LocalVault store — OS keychain primary, AES-256-GCM file fallback. + * + * Passphrase priority: + * 1. CLAUDE_LOCAL_VAULT_PASSPHRASE env var + * 2. ~/.claude/.local-vault-passphrase (mode 600 on POSIX) + * 3. Auto-generate + write to file (warns user to backup) + * + * Fallback file: ~/.claude/local-vault.enc.json (gitignored) + * + * Security invariants: + * - AES-256-GCM with per-record random IV; scryptSync KDF for passphrase + * - Vault-level 16-byte random salt stored in vault file header + * - D1: value size capped at MAX_SECRET_BYTES (64 KB) + * - B1: derived key buffer is zeroed after use (best-effort) + * - C1: vault file writes use tmp+rename (atomic on POSIX) + * - C5: passphrase file creation uses 'wx' exclusive flag (no double-write) + * - A2: readVaultFile differentiates ENOENT vs JSON-parse error + * - F1/F2: scryptSync KDF + per-vault salt (no rainbow tables) + * - G4: decryption error includes recovery instructions + */ + +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, +} from 'node:crypto' +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + chmodSync, + renameSync, + rmSync, +} from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { logError } from '../../utils/log.js' +import { KeychainUnavailableError, tryKeychain } from './keychain.js' + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Maximum secret value size: 64 KB (OS keychain typically < 4 KB; file fallback keeps overhead low). */ +const MAX_SECRET_BYTES = 64 * 1024 + +/** AES-GCM algorithm. */ +const ALGORITHM = 'aes-256-gcm' as const +const IV_BYTES = 12 +const TAG_BYTES = 16 +const KEY_BYTES = 32 +const SALT_BYTES = 16 + +/** scrypt parameters: N=16384 (2^14), r=8, p=1. OWASP-recommended minimum for interactive. */ +const SCRYPT_PARAMS: Parameters[3] = { N: 16384, r: 8, p: 1 } + +// ── Error types ─────────────────────────────────────────────────────────────── + +export class LocalVaultDecryptionError extends Error { + constructor(reason: string) { + super( + `LocalVault decryption failed: ${reason}. ` + + 'Restore from your backup of ~/.claude/.local-vault-passphrase, ' + + 'or delete ~/.claude/local-vault.enc.json to reset (DESTROYS ALL SECRETS).', + ) + this.name = 'LocalVaultDecryptionError' + } +} + +export class LocalVaultValueTooLargeError extends Error { + constructor(byteLength: number) { + super( + `LocalVault: secret value is too large (${byteLength} bytes). ` + + `Maximum allowed is ${MAX_SECRET_BYTES} bytes (${MAX_SECRET_BYTES / 1024} KB). ` + + 'Use external storage for large data.', + ) + this.name = 'LocalVaultValueTooLargeError' + } +} + +// ── Path helpers ────────────────────────────────────────────────────────────── + +function getClaudeDir(): string { + return process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude') +} + +function getVaultFilePath(): string { + return join(getClaudeDir(), 'local-vault.enc.json') +} + +function getPassphraseFilePath(): string { + return join(getClaudeDir(), '.local-vault-passphrase') +} + +// ── Passphrase management ───────────────────────────────────────────────────── + +/** + * Derives a 32-byte AES key from a passphrase + salt using scryptSync. + * + * F1/F2 fix: replaces single SHA-256 with memory-hard KDF + per-vault salt. + * The salt is stored in the vault file header so it survives process restarts. + * For the auto-generated 64-hex passphrase (256 bits entropy) this is defense- + * in-depth; for user-provided low-entropy passphrases it is mandatory. + */ +function deriveKey(passphrase: string, salt: Buffer): Buffer { + return scryptSync(passphrase, salt, KEY_BYTES, SCRYPT_PARAMS) as Buffer +} + +/** + * Get or create the passphrase. + * + * C5 fix: uses { flag: 'wx' } (exclusive create) for atomic first-run write. + * If EEXIST (race: another process wrote first), re-reads from disk. + */ +async function getOrCreatePassphrase(): Promise { + // Priority 1: env var + const envVal = process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + if (envVal) return envVal + + const passphraseFile = getPassphraseFilePath() + + // Priority 2: existing passphrase file + if (existsSync(passphraseFile)) { + return readFileSync(passphraseFile, 'utf8').trim() + } + + // Priority 3: auto-generate + write to file (exclusive create to avoid double-write) + const claudeDir = getClaudeDir() + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }) + } + + const generated = randomBytes(32).toString('hex') + try { + // C5: 'wx' flag means exclusive create — EEXIST if another process wrote first + writeFileSync(passphraseFile, generated, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }) + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + // Another concurrent first-run wrote the file — use theirs + return readFileSync(passphraseFile, 'utf8').trim() + } + throw err + } + + // Ensure mode 600 even if umask interfered + try { + chmodSync(passphraseFile, 0o600) + } catch { + // A4: Windows — best effort; user cannot act before encryption proceeds. + // Recommend env var as the secure alternative. + logError( + new Error( + 'LocalVault: could not set passphrase file permissions on Windows. ' + + 'To secure your vault, set CLAUDE_LOCAL_VAULT_PASSPHRASE env var instead of relying on the passphrase file. ' + + 'Run: icacls "%USERPROFILE%\\.claude\\.local-vault-passphrase" /inheritance:r /grant:r "%USERNAME%":F', + ), + ) + } + + // E5: Use logError (consistent with rest of file) instead of console.warn + logError( + new Error( + '[LocalVault] Generated new passphrase file: ' + + passphraseFile + + ' — Back it up! Losing this file means losing access to your encrypted vault.', + ), + ) + + return generated +} + +// ── Vault file format ───────────────────────────────────────────────────────── + +type EncryptedRecord = { + iv: string // hex + tag: string // hex + data: string // hex +} + +type VaultFile = { + /** F1/F2: per-vault KDF salt, 32 hex chars (16 bytes). */ + _salt?: string + /** Version marker for forward compatibility. */ + _version?: number + [key: string]: EncryptedRecord | string | number | undefined +} + +// ── Crypto primitives ───────────────────────────────────────────────────────── + +function encrypt( + plaintext: string, + key: Buffer, + entryKey: string, +): EncryptedRecord { + // New IV per encryption — invariant: no IV reuse + const iv = randomBytes(IV_BYTES) + const cipher = createCipheriv(ALGORITHM, key, iv) + // F3: bind entry key as AAD so swapping records fails GCM authentication + cipher.setAAD(Buffer.from(entryKey, 'utf8')) + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]) + const tag = cipher.getAuthTag() + return { + iv: iv.toString('hex'), + tag: tag.toString('hex'), + data: encrypted.toString('hex'), + } +} + +function decrypt( + record: EncryptedRecord, + key: Buffer, + entryKey: string, +): string { + let iv: Buffer + let tag: Buffer + let data: Buffer + try { + iv = Buffer.from(record.iv, 'hex') + tag = Buffer.from(record.tag, 'hex') + data = Buffer.from(record.data, 'hex') + } catch { + throw new LocalVaultDecryptionError('corrupted record encoding') + } + + if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) { + throw new LocalVaultDecryptionError('invalid IV or tag length') + } + + const decipher = createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(tag) + // F3: must supply the same AAD used during encryption + decipher.setAAD(Buffer.from(entryKey, 'utf8')) + let decrypted: Buffer + try { + decrypted = Buffer.concat([decipher.update(data), decipher.final()]) + } catch { + // Do not leak partial decrypted bytes + throw new LocalVaultDecryptionError( + 'authentication tag mismatch — wrong passphrase or tampered data', + ) + } + // H3 fix (codecov-100 audit): use a fatal TextDecoder so invalid UTF-8 + // surfaces as a thrown error instead of being silently replaced with + // U+FFFD. AES-GCM authentication catches *most* tampering, but the + // decryption succeeds before we get here — and a vault written by a + // bug in an older version (or by a manual `local-vault.enc.json` + // edit) could still contain non-UTF-8 bytes. Without this check the + // caller would receive a lossy string and have no way to detect that + // their secret has been corrupted. + try { + return new TextDecoder('utf-8', { fatal: true }).decode(decrypted) + } catch { + throw new LocalVaultDecryptionError( + 'decrypted payload is not valid UTF-8 — vault record may be corrupted', + ) + } +} + +// ── Vault file I/O ──────────────────────────────────────────────────────────── + +async function readVaultFile(): Promise { + const filePath = getVaultFilePath() + if (!existsSync(filePath)) return {} + let raw: string + try { + raw = await readFile(filePath, 'utf8') + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'ENOENT') return {} + // Rethrow unexpected read errors (permissions, hardware fault) + throw err + } + // A2: differentiate parse error from absence + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new LocalVaultDecryptionError( + 'vault file is corrupt (invalid JSON) — restore from backup', + ) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new LocalVaultDecryptionError( + 'vault file has unexpected format — restore from backup', + ) + } + return parsed as VaultFile +} + +async function writeVaultFile(data: VaultFile): Promise { + const claudeDir = getClaudeDir() + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }) + } + const filePath = getVaultFilePath() + // C1: atomic write — tmp file + rename (POSIX rename(2) is atomic) + const vaultDir = join(filePath, '..') + const tmpPath = join( + vaultDir, + `.local-vault-${randomBytes(8).toString('hex')}.tmp`, + ) + try { + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8') + renameSync(tmpPath, filePath) + } catch (err) { + // Clean up tmp on failure + try { + rmSync(tmpPath, { force: true }) + } catch { + /* ignore cleanup error */ + } + throw err + } +} + +/** Get or create the per-vault salt, storing it in the vault file. */ +async function getOrCreateSalt(vaultData: VaultFile): Promise { + if ( + typeof vaultData['_salt'] === 'string' && + vaultData['_salt'].length === SALT_BYTES * 2 + ) { + return Buffer.from(vaultData['_salt'], 'hex') + } + // Generate new salt and persist it (the caller will write the vault file) + const salt = randomBytes(SALT_BYTES) + vaultData['_salt'] = salt.toString('hex') + vaultData['_version'] = 2 + return salt +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +export async function setSecret(key: string, value: string): Promise { + if (!key) throw new Error('key must not be empty') + + // D1: Guard against unbounded value sizes + const byteLength = Buffer.byteLength(value, 'utf8') + if (byteLength > MAX_SECRET_BYTES) { + throw new LocalVaultValueTooLargeError(byteLength) + } + + // Primary: OS keychain + try { + await tryKeychain.set(key, value) + await tryKeychain._addToIndex(key) + return + } catch (err: unknown) { + if (!(err instanceof KeychainUnavailableError)) { + throw err + } + // Keychain unavailable → fall through to file + // A: Not silently swallowed; user gets a console warning each call + logError( + new Error( + '[LocalVault] OS keychain not available, falling back to encrypted file. ' + + 'Install platform keychain or set CLAUDE_LOCAL_VAULT_PASSPHRASE env.', + ), + ) + } + + // Fallback: encrypted file + const passphrase = await getOrCreatePassphrase() + const vaultData = await readVaultFile() + const salt = await getOrCreateSalt(vaultData) + + // B1: zero the key buffer after use regardless of success/failure + const key256 = deriveKey(passphrase, salt) + try { + vaultData[key] = encrypt(value, key256, key) + await writeVaultFile(vaultData) + } finally { + key256.fill(0) + } +} + +export async function getSecret(key: string): Promise { + // Primary: OS keychain + try { + const val = await tryKeychain.get(key) + return val + } catch (err: unknown) { + if (!(err instanceof KeychainUnavailableError)) { + throw err + } + // Keychain unavailable — fall through to file (no log needed on read path) + } + + // Fallback: encrypted file + const vaultData = await readVaultFile() + const record = vaultData[key] + if (!record || typeof record !== 'object' || Array.isArray(record)) + return null + + // Detect old format: no salt field → record was encrypted without scrypt KDF. + // The new AAD binding also means old records will fail authentication. + // Instruct user to re-set secrets encrypted under the old format. + if (typeof vaultData['_salt'] !== 'string') { + throw new LocalVaultDecryptionError( + 'vault was created with an older format (no KDF salt). ' + + 'Please re-set your secrets using /local-vault set to upgrade to the secure format', + ) + } + + const passphrase = await getOrCreatePassphrase() + const salt = Buffer.from(vaultData['_salt'], 'hex') + + // B1: zero the key buffer after use + const key256 = deriveKey(passphrase, salt) + try { + return decrypt(record as EncryptedRecord, key256, key) + } finally { + key256.fill(0) + } +} + +export async function deleteSecret(key: string): Promise { + // Primary: OS keychain + try { + const deleted = await tryKeychain.delete(key) + await tryKeychain._removeFromIndex(key) + return deleted + } catch (err: unknown) { + if (!(err instanceof KeychainUnavailableError)) { + throw err + } + } + + // Fallback: encrypted file + const vaultData = await readVaultFile() + if (!(key in vaultData)) return false + const updated = { ...vaultData } + delete updated[key] + await writeVaultFile(updated) + return true +} + +export async function listKeys(): Promise { + // Primary: OS keychain index + try { + return await tryKeychain.list() + } catch (err: unknown) { + if (!(err instanceof KeychainUnavailableError)) { + throw err + } + } + + // Fallback: encrypted file keys (no decryption needed — just keys) + const vaultData = await readVaultFile() + // Filter out internal metadata keys + return Object.keys(vaultData).filter(k => !k.startsWith('_')) +} + +/** Mask a secret value for display: first 4 chars + ... + last 2 chars + length */ +export function maskSecret(value: string): string { + if (value.length <= 6) return `***[len=${value.length}]` + return `${value.slice(0, 4)}...[len=${value.length}]` +} diff --git a/src/utils/__tests__/effort.test.ts b/src/utils/__tests__/effort.test.ts index d2494aa1c..affb45f52 100644 --- a/src/utils/__tests__/effort.test.ts +++ b/src/utils/__tests__/effort.test.ts @@ -32,8 +32,8 @@ const { // ─── EFFORT_LEVELS constant ──────────────────────────────────────────── describe("EFFORT_LEVELS", () => { - test("contains the four canonical levels", () => { - expect(EFFORT_LEVELS).toEqual(["low", "medium", "high", "max"]); + test("contains the five canonical levels", () => { + expect(EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); }); }); @@ -223,12 +223,6 @@ describe("getEffortLevelDescription", () => { const desc = getEffortLevelDescription("max"); expect(desc).toContain("Maximum"); }); - - test("max description does not contain model names", () => { - const desc = getEffortLevelDescription("max"); - expect(desc).not.toContain("Opus"); - expect(desc).not.toContain("DeepSeek"); - }); }); // ─── resolvePickerEffortPersistence ──────────────────────────────────── diff --git a/src/utils/__tests__/formatBriefTimestamp.test.ts b/src/utils/__tests__/formatBriefTimestamp.test.ts index 8dafb7b0a..9a89b16fc 100644 --- a/src/utils/__tests__/formatBriefTimestamp.test.ts +++ b/src/utils/__tests__/formatBriefTimestamp.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { formatBriefTimestamp } from "../formatBriefTimestamp"; +// Force en-US locale for deterministic test output regardless of system LANG +process.env.LC_ALL = 'en-US'; +// Force UTC timezone — system is CST (UTC+8) which shifts weekdays +process.env.TZ = 'UTC'; + describe("formatBriefTimestamp", () => { // Fixed "now" for deterministic tests: 2026-04-02T14:00:00Z (Thursday) const now = new Date("2026-04-02T14:00:00Z");