From b8d86e527924d3963f1b8f3b345508c3ae6653b8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 9 May 2026 23:04:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Local=20Vault=20?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E5=AD=98=E5=82=A8=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AES-256-GCM 加密 vault,支持 OS keychain 和加密文件回退, scrypt KDF 密钥派生,64KB secret 上限。 Co-Authored-By: glm-5-turbo --- .../localVault/__tests__/keychain.test.ts | 91 ++++ .../localVault/__tests__/store.test.ts | 468 ++++++++++++++++++ src/services/localVault/keychain.ts | 133 +++++ src/services/localVault/store.ts | 464 +++++++++++++++++ src/types/internal-modules.d.ts | 9 + src/utils/__tests__/localValidate.test.ts | 90 ++++ src/utils/localValidate.ts | 56 +++ src/utils/sanitizeId.ts | 14 + 8 files changed, 1325 insertions(+) create mode 100644 src/services/localVault/__tests__/keychain.test.ts create mode 100644 src/services/localVault/__tests__/store.test.ts create mode 100644 src/services/localVault/keychain.ts create mode 100644 src/services/localVault/store.ts create mode 100644 src/utils/__tests__/localValidate.test.ts create mode 100644 src/utils/localValidate.ts create mode 100644 src/utils/sanitizeId.ts diff --git a/src/services/localVault/__tests__/keychain.test.ts b/src/services/localVault/__tests__/keychain.test.ts new file mode 100644 index 000000000..f8e6b6c0c --- /dev/null +++ b/src/services/localVault/__tests__/keychain.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test' +import { logMock } from '../../../../tests/mocks/log.js' + +mock.module('src/utils/log.ts', logMock) +mock.module('bun:bundle', () => ({ feature: () => false })) + +// ── In-memory store backing the mock ───────────────────────────────────────── + +const store: Record = {} + +// ── Class-based Entry mock ──────────────────────────────────────────────────── + +class MockEntry { + constructor( + public service: string, + public account: string, + ) {} + + getPassword(): string | null { + return store[this.account] ?? null + } + + setPassword(pw: string): void { + store[this.account] = pw + } + + deletePassword(): boolean { + if (this.account in store) { + delete store[this.account] + return true + } + return false + } +} + +mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry })) + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('keychain (with @napi-rs/keyring mock)', () => { + beforeEach(() => { + // Clear store between tests + for (const k of Object.keys(store)) delete store[k] + // Reset the module load cache so keychain re-imports the mocked module + const keychainMod = require.cache?.['../keychain.js'] + if (keychainMod) delete require.cache['../keychain.js'] + }) + + test('set and get round-trip', async () => { + const { tryKeychain, _resetKeychainModuleCache } = await import( + '../keychain.js' + ) + _resetKeychainModuleCache() + await tryKeychain.set('MY_KEY', 'my_secret_value') + const result = await tryKeychain.get('MY_KEY') + expect(result).toBe('my_secret_value') + }) + + test('get returns null for missing key', async () => { + const { tryKeychain, _resetKeychainModuleCache } = await import( + '../keychain.js' + ) + _resetKeychainModuleCache() + const result = await tryKeychain.get('NONEXISTENT_KEY') + expect(result).toBeNull() + }) + + test('delete returns true for existing key', async () => { + const { tryKeychain, _resetKeychainModuleCache } = await import( + '../keychain.js' + ) + _resetKeychainModuleCache() + await tryKeychain.set('DELETE_ME', 'value') + const result = await tryKeychain.delete('DELETE_ME') + expect(result).toBe(true) + expect(await tryKeychain.get('DELETE_ME')).toBeNull() + }) + + test('KeychainUnavailableError thrown when module exports invalid shape', async () => { + // Temporarily replace with a bad module + mock.module('@napi-rs/keyring', () => ({ Entry: null })) + const { tryKeychain, KeychainUnavailableError, _resetKeychainModuleCache } = + await import('../keychain.js') + _resetKeychainModuleCache() + await expect(tryKeychain.get('x')).rejects.toBeInstanceOf( + KeychainUnavailableError, + ) + // Restore + mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry })) + }) +}) diff --git a/src/services/localVault/__tests__/store.test.ts b/src/services/localVault/__tests__/store.test.ts new file mode 100644 index 000000000..55da4a7ea --- /dev/null +++ b/src/services/localVault/__tests__/store.test.ts @@ -0,0 +1,468 @@ +import { + describe, + test, + expect, + mock, + beforeEach, + afterEach, + spyOn, +} from 'bun:test' +import { + mkdtempSync, + rmSync, + writeFileSync, + statSync, + readFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { logMock } from '../../../../tests/mocks/log.js' + +mock.module('src/utils/log.ts', logMock) +mock.module('bun:bundle', () => ({ feature: () => false })) + +// ── Keychain mock (unavailable by default to test fallback path) ─────────────── + +import { KeychainUnavailableError } from '../keychain.js' + +const keychainUnavailable = async (): Promise => { + throw new KeychainUnavailableError('test: keychain mocked as unavailable') +} + +const keychainMock = { + set: mock(keychainUnavailable), + get: mock(keychainUnavailable), + delete: mock(keychainUnavailable), + list: mock(keychainUnavailable), + _addToIndex: mock(keychainUnavailable), + _removeFromIndex: mock(keychainUnavailable), +} + +mock.module('../keychain.js', () => ({ + KeychainUnavailableError, + tryKeychain: keychainMock, + _resetKeychainModuleCache: () => {}, +})) + +// ── Crypto fallback tests ───────────────────────────────────────────────────── + +describe('store (AES-256-GCM file fallback)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'local-vault-test-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + // Use a fixed passphrase via env to avoid file creation + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + // Reset all keychain mocks to unavailable + keychainMock.set.mockImplementation(keychainUnavailable) + keychainMock.get.mockImplementation(keychainUnavailable) + keychainMock.delete.mockImplementation(keychainUnavailable) + keychainMock.list.mockImplementation(keychainUnavailable) + keychainMock._addToIndex.mockImplementation(keychainUnavailable) + keychainMock._removeFromIndex.mockImplementation(keychainUnavailable) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + }) + + test('round-trip: set then get returns same value', async () => { + const { setSecret, getSecret } = await import('../store.js') + await setSecret('API_KEY', 'super-secret-value-abc123') + const result = await getSecret('API_KEY') + expect(result).toBe('super-secret-value-abc123') + }) + + test('get returns null for missing key', async () => { + const { getSecret } = await import('../store.js') + const result = await getSecret('NONEXISTENT_KEY') + expect(result).toBeNull() + }) + + test('delete removes key; subsequent get returns null', async () => { + const { setSecret, getSecret, deleteSecret } = await import('../store.js') + await setSecret('TO_DELETE', 'temporary-value') + const deleted = await deleteSecret('TO_DELETE') + expect(deleted).toBe(true) + expect(await getSecret('TO_DELETE')).toBeNull() + }) + + test('delete returns false for nonexistent key', async () => { + const { deleteSecret } = await import('../store.js') + const result = await deleteSecret('GHOST_KEY') + expect(result).toBe(false) + }) + + test('listKeys returns stored keys without values', async () => { + const { setSecret, listKeys } = await import('../store.js') + await setSecret('KEY_A', 'value-a') + await setSecret('KEY_B', 'value-b') + const keys = await listKeys() + expect(keys).toContain('KEY_A') + expect(keys).toContain('KEY_B') + expect(keys.join('')).not.toContain('value-a') + expect(keys.join('')).not.toContain('value-b') + }) + + test('wrong passphrase throws LocalVaultDecryptionError (does not leak bytes)', async () => { + const { setSecret } = await import('../store.js') + await setSecret('SENSITIVE', 'my-secret-12345') + + // Change passphrase to simulate wrong key + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'wrong-passphrase-different-xxxxx' + const { getSecret, LocalVaultDecryptionError } = await import('../store.js') + await expect(getSecret('SENSITIVE')).rejects.toBeInstanceOf( + LocalVaultDecryptionError, + ) + // Restore + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + }) + + test('file does not exist → getSecret returns null (not error)', async () => { + const { getSecret } = await import('../store.js') + const result = await getSecret('ANY_KEY') + expect(result).toBeNull() + }) + + test('corrupted JSON vault file → getSecret throws LocalVaultDecryptionError (A2 fix)', async () => { + writeFileSync(join(tmpDir, 'local-vault.enc.json'), 'not-valid-json') + const { getSecret, LocalVaultDecryptionError } = await import('../store.js') + await expect(getSecret('ANY_KEY')).rejects.toBeInstanceOf( + LocalVaultDecryptionError, + ) + }) + + test('value at exactly 64KB round-trips successfully', async () => { + const { setSecret, getSecret } = await import('../store.js') + const exactValue = 'X'.repeat(64 * 1024) + await setSecret('LARGE_KEY', exactValue) + const result = await getSecret('LARGE_KEY') + expect(result).toBe(exactValue) + }) + + test('value over 64KB is rejected by setSecret (D1 fix)', async () => { + const { setSecret, LocalVaultValueTooLargeError } = await import( + '../store.js' + ) + const tooLarge = 'X'.repeat(64 * 1024 + 1) + await expect(setSecret('LARGE_KEY', tooLarge)).rejects.toBeInstanceOf( + LocalVaultValueTooLargeError, + ) + }) + + test('Unicode key round-trip', async () => { + const { setSecret, getSecret } = await import('../store.js') + await setSecret('KEY_🔑', 'unicode-secret-日本語') + const result = await getSecret('KEY_🔑') + expect(result).toBe('unicode-secret-日本語') + }) + + test('IV is unique per encryption (AES-GCM invariant)', async () => { + // Write two entries; IVs in vault file should differ + const { setSecret } = await import('../store.js') + await setSecret('KEY_1', 'value-1') + await setSecret('KEY_2', 'value-2') + const vaultRaw = readFileSync(join(tmpDir, 'local-vault.enc.json'), 'utf8') + const vault = JSON.parse(vaultRaw) as Record + // Only check actual encrypted records (skip metadata keys like _salt, _version) + const records = Object.entries(vault) + .filter(([k]) => !k.startsWith('_')) + .map(([, v]) => (v as { iv: string }).iv) + expect(new Set(records).size).toBe(records.length) // all IVs unique + }) + + test('passphrase file mode 600 on POSIX', async () => { + // Remove env passphrase to force file creation + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + const { setSecret } = await import('../store.js') + await setSecret('MODE_TEST', 'value') + const passphraseFile = join(tmpDir, '.local-vault-passphrase') + if (process.platform !== 'win32') { + const stat = statSync(passphraseFile) + const mode = stat.mode & 0o777 + expect(mode).toBe(0o600) + } + // On Windows: file should exist (mode check is best-effort) + const { existsSync } = await import('node:fs') + expect(existsSync(passphraseFile)).toBe(true) + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + }) +}) + +// ── maskSecret tests ────────────────────────────────────────────────────────── + +describe('maskSecret', () => { + test('masks long secret correctly', async () => { + const { maskSecret } = await import('../store.js') + const masked = maskSecret('ABCDEFGHIJKLMNOP') + expect(masked.startsWith('ABCD')).toBe(true) + expect(masked).toContain('...') + expect(masked).not.toBe('ABCDEFGHIJKLMNOP') + }) + + test('short secret uses length notation', async () => { + const { maskSecret } = await import('../store.js') + expect(maskSecret('abc')).toContain('len=3') + expect(maskSecret('abc')).not.toContain('abc') + }) +}) + +// ── I1: Security invariant — secret never appears in logs ───────────────────── + +describe('store: security invariants (I1)', () => { + let tmpDir: string + const SECRET_VALUE = 'super-secret-never-log-me-abc999' + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'vault-sec-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + keychainMock.set.mockImplementation(keychainUnavailable) + keychainMock.get.mockImplementation(keychainUnavailable) + keychainMock.delete.mockImplementation(keychainUnavailable) + keychainMock.list.mockImplementation(keychainUnavailable) + keychainMock._addToIndex.mockImplementation(keychainUnavailable) + keychainMock._removeFromIndex.mockImplementation(keychainUnavailable) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + }) + + test('secret value never appears in console.warn calls after setSecret', async () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}) + const { setSecret } = await import('../store.js') + await setSecret('MY_KEY', SECRET_VALUE) + const allWarnCalls = warnSpy.mock.calls.flat().map(String).join(' ') + expect(allWarnCalls).not.toContain(SECRET_VALUE) + warnSpy.mockRestore() + }) + + test('secret value never appears in vault file keys (only encrypted blob)', async () => { + const { setSecret } = await import('../store.js') + await setSecret('MY_KEY', SECRET_VALUE) + const vaultPath = join(tmpDir, 'local-vault.enc.json') + const vaultContent = readFileSync(vaultPath, 'utf8') + // The plaintext secret must not appear in the vault file + expect(vaultContent).not.toContain(SECRET_VALUE) + // The key name IS stored (by design), but the value must not be + expect(vaultContent).toContain('MY_KEY') + }) +}) + +// ── I2: AES-GCM tamper detection ────────────────────────────────────────────── + +describe('store: AES-GCM tamper detection (I2)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'vault-tamper-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + keychainMock.set.mockImplementation(keychainUnavailable) + keychainMock.get.mockImplementation(keychainUnavailable) + keychainMock.delete.mockImplementation(keychainUnavailable) + keychainMock.list.mockImplementation(keychainUnavailable) + keychainMock._addToIndex.mockImplementation(keychainUnavailable) + keychainMock._removeFromIndex.mockImplementation(keychainUnavailable) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + }) + + test('flipping a byte in data causes LocalVaultDecryptionError', async () => { + const { setSecret, getSecret, LocalVaultDecryptionError } = await import( + '../store.js' + ) + await setSecret('TAMPER_KEY', 'original-value-to-tamper') + const vaultPath = join(tmpDir, 'local-vault.enc.json') + const vault = JSON.parse(readFileSync(vaultPath, 'utf8')) as Record< + string, + { iv: string; tag: string; data: string } + > + // Flip last byte of data hex + const record = vault['TAMPER_KEY']! + const dataHex = record.data + const flippedByte = (parseInt(dataHex.slice(-2), 16) ^ 0xff) + .toString(16) + .padStart(2, '0') + vault['TAMPER_KEY'] = { + ...record, + data: dataHex.slice(0, -2) + flippedByte, + } + writeFileSync(vaultPath, JSON.stringify(vault), 'utf8') + await expect(getSecret('TAMPER_KEY')).rejects.toBeInstanceOf( + LocalVaultDecryptionError, + ) + }) + + test('flipping a byte in tag causes LocalVaultDecryptionError', async () => { + const { setSecret, getSecret, LocalVaultDecryptionError } = await import( + '../store.js' + ) + await setSecret('TAMPER_TAG', 'original-value-tag-tamper') + const vaultPath = join(tmpDir, 'local-vault.enc.json') + const vault = JSON.parse(readFileSync(vaultPath, 'utf8')) as Record< + string, + { iv: string; tag: string; data: string } + > + const record = vault['TAMPER_TAG']! + const tagHex = record.tag + const flippedByte = (parseInt(tagHex.slice(-2), 16) ^ 0xff) + .toString(16) + .padStart(2, '0') + vault['TAMPER_TAG'] = { ...record, tag: tagHex.slice(0, -2) + flippedByte } + writeFileSync(vaultPath, JSON.stringify(vault), 'utf8') + await expect(getSecret('TAMPER_TAG')).rejects.toBeInstanceOf( + LocalVaultDecryptionError, + ) + }) +}) + +// ── H3 fix (codecov-100 audit): invalid-UTF-8 decryption surfaces as error ──── + +describe('store: invalid-UTF-8 decryption rejection (H3)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'vault-utf8-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + keychainMock.set.mockImplementation(keychainUnavailable) + keychainMock.get.mockImplementation(keychainUnavailable) + keychainMock.delete.mockImplementation(keychainUnavailable) + keychainMock.list.mockImplementation(keychainUnavailable) + keychainMock._addToIndex.mockImplementation(keychainUnavailable) + keychainMock._removeFromIndex.mockImplementation(keychainUnavailable) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + }) + + test('regression: decrypted payload with invalid UTF-8 throws LocalVaultDecryptionError (no silent U+FFFD)', async () => { + // We craft a vault file whose encrypted record decrypts to a buffer + // containing invalid UTF-8 (lone continuation byte 0xC3 followed by + // 0x28 — '(' — which is NOT a valid continuation byte). + // The encrypted record must pass GCM authentication, so we encrypt + // the malformed bytes ourselves with the same passphrase + salt as + // the store would derive. + const { LocalVaultDecryptionError, getSecret } = await import('../store.js') + const { createCipheriv, randomBytes, scryptSync } = await import( + 'node:crypto' + ) + + // Mirror the constants from store.ts + const ALGORITHM = 'aes-256-gcm' as const + const IV_BYTES = 12 + const KEY_BYTES = 32 + const SALT_BYTES = 16 + const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } + + const passphrase = 'test-passphrase-fixed-32chars-xxx' + const salt = randomBytes(SALT_BYTES) + const key256 = scryptSync( + passphrase, + salt, + KEY_BYTES, + SCRYPT_PARAMS, + ) as Buffer + + // Invalid UTF-8 sequence: lone continuation byte / overlong / truncated + // multi-byte. 0xC3 0x28 is the canonical "invalid 2-byte sequence" example. + const invalidUtf8 = Buffer.from([0xc3, 0x28, 0xa0, 0xa1]) + + const iv = randomBytes(IV_BYTES) + const cipher = createCipheriv(ALGORITHM, key256, iv) + const entryKey = 'BAD_UTF8' + cipher.setAAD(Buffer.from(entryKey, 'utf8')) + const encrypted = Buffer.concat([ + cipher.update(invalidUtf8), + cipher.final(), + ]) + const tag = cipher.getAuthTag() + + const vaultData = { + _salt: salt.toString('hex'), + _version: 2, + [entryKey]: { + iv: iv.toString('hex'), + tag: tag.toString('hex'), + data: encrypted.toString('hex'), + }, + } + writeFileSync( + join(tmpDir, 'local-vault.enc.json'), + JSON.stringify(vaultData), + 'utf8', + ) + + // Old code: returned a string with U+FFFD replacement chars (corruption + // undetectable to caller). New code: throws LocalVaultDecryptionError. + await expect(getSecret(entryKey)).rejects.toBeInstanceOf( + LocalVaultDecryptionError, + ) + await expect(getSecret(entryKey)).rejects.toMatchObject({ + message: expect.stringMatching(/UTF-8|corrupted/i), + }) + }) + + test('valid UTF-8 (CJK / emoji) still round-trips after H3 fix', async () => { + // Sanity: H3's fatal TextDecoder must not break valid multi-byte UTF-8. + const { setSecret, getSecret } = await import('../store.js') + const value = '日本語🎉🌟αβγ test 123' + await setSecret('UTF8_OK', value) + expect(await getSecret('UTF8_OK')).toBe(value) + }) +}) + +// ── D1: Value size limit ─────────────────────────────────────────────────────── + +describe('store: value size limit (D1)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'vault-size-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + keychainMock.set.mockImplementation(keychainUnavailable) + keychainMock._addToIndex.mockImplementation(keychainUnavailable) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + }) + + test('setSecret rejects value >64KB', async () => { + const { setSecret } = await import('../store.js') + const bigValue = 'X'.repeat(64 * 1024 + 1) + await expect(setSecret('BIG_KEY', bigValue)).rejects.toThrow() + }) + + test('setSecret accepts value exactly at 64KB', async () => { + const { setSecret, getSecret } = await import('../store.js') + const exactValue = 'X'.repeat(64 * 1024) + await expect(setSecret('EXACT_KEY', exactValue)).resolves.toBeUndefined() + expect(await getSecret('EXACT_KEY')).toBe(exactValue) + }) +}) diff --git a/src/services/localVault/keychain.ts b/src/services/localVault/keychain.ts new file mode 100644 index 000000000..af1a5f857 --- /dev/null +++ b/src/services/localVault/keychain.ts @@ -0,0 +1,133 @@ +/** + * Thin wrapper around @napi-rs/keyring OS keychain. + * If the native module is unavailable (platform not supported, module missing), + * throws KeychainUnavailableError so that store.ts can fall back to encrypted + * file storage. + */ + +export class KeychainUnavailableError extends Error { + constructor(reason: string) { + super(`OS keychain not available: ${reason}`) + this.name = 'KeychainUnavailableError' + } +} + +const SERVICE_NAME = 'claude-code-local-vault' + +type KeyringEntry = { + getPassword: () => string | null + setPassword: (password: string) => void + deletePassword: () => boolean +} + +type KeyringModule = { + Entry: new (service: string, account: string) => KeyringEntry +} + +let _mod: KeyringModule | null | 'not-tried' = 'not-tried' + +async function loadModule(): Promise { + if (_mod !== 'not-tried') { + if (_mod === null) + throw new KeychainUnavailableError('module load failed previously') + return _mod + } + try { + // Dynamic import so the rest of the codebase compiles even without the module. + const m = (await import('@napi-rs/keyring')) as unknown as KeyringModule + if (!m || typeof m.Entry !== 'function') { + _mod = null + throw new KeychainUnavailableError('module does not export Entry') + } + _mod = m + return m + } catch (err: unknown) { + if (err instanceof KeychainUnavailableError) throw err + _mod = null + throw new KeychainUnavailableError( + err instanceof Error ? err.message : String(err), + ) + } +} + +/** + * Reset module cache — for testing only. + * B2: intentionally not exported from the package's public API. + * Only imported via the tests' mock.module() boundary. + * @internal + */ +export function _resetKeychainModuleCache(): void { + _mod = 'not-tried' +} + +export const tryKeychain = { + async set(account: string, value: string): Promise { + const mod = await loadModule() + const entry = new mod.Entry(SERVICE_NAME, account) + entry.setPassword(value) + }, + + async get(account: string): Promise { + const mod = await loadModule() + const entry = new mod.Entry(SERVICE_NAME, account) + return entry.getPassword() + }, + + async delete(account: string): Promise { + const mod = await loadModule() + const entry = new mod.Entry(SERVICE_NAME, account) + return entry.deletePassword() + }, + + /** + * Keyring has no native "list all" — we maintain our own index in a + * dedicated account named __index__. + * + * A3 fix: a corrupt index throws KeychainUnavailableError so the caller + * can fall back to the file vault rather than silently returning [] and + * stranding existing keys (they become undeletable via delete()). + * + * C4 note: index read-modify-write is not atomic across processes. In + * practice /local-vault set is user-interactive (not concurrently scripted), + * so the advisory risk is acceptable. A future version can use Bun.lock or + * an exclusive file lock for cross-process safety. + */ + async list(): Promise { + const mod = await loadModule() + const indexEntry = new mod.Entry(SERVICE_NAME, '__index__') + const raw = indexEntry.getPassword() + if (!raw) return [] + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + // A3: corrupt index — throw so caller can fall back, not silently lose key references + throw new KeychainUnavailableError( + 'keychain index is corrupt (invalid JSON). Reset via: /local-vault list (will regenerate index on next set).', + ) + } + if (Array.isArray(parsed)) { + return (parsed as unknown[]).filter( + (x): x is string => typeof x === 'string', + ) + } + return [] + }, + + async _addToIndex(account: string): Promise { + const mod = await loadModule() + const indexEntry = new mod.Entry(SERVICE_NAME, '__index__') + const existing = await this.list() + if (!existing.includes(account)) { + indexEntry.setPassword(JSON.stringify([...existing, account])) + } + }, + + async _removeFromIndex(account: string): Promise { + const mod = await loadModule() + const indexEntry = new mod.Entry(SERVICE_NAME, '__index__') + const existing = await this.list() + const updated = existing.filter(k => k !== account) + indexEntry.setPassword(JSON.stringify(updated)) + }, +} diff --git a/src/services/localVault/store.ts b/src/services/localVault/store.ts new file mode 100644 index 000000000..88d8de4b0 --- /dev/null +++ b/src/services/localVault/store.ts @@ -0,0 +1,464 @@ +/** + * 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, tmpdir } 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 tmpPath = join( + tmpdir(), + `.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 { + // 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/types/internal-modules.d.ts b/src/types/internal-modules.d.ts index 7d2606df9..1ea39dc67 100644 --- a/src/types/internal-modules.d.ts +++ b/src/types/internal-modules.d.ts @@ -48,3 +48,12 @@ declare module 'asciichart' { export { plot } export default { plot } } + +declare module '@napi-rs/keyring' { + export class Entry { + constructor(service: string, account: string) + getPassword(): string | null + setPassword(password: string): void + deletePassword(): boolean + } +} diff --git a/src/utils/__tests__/localValidate.test.ts b/src/utils/__tests__/localValidate.test.ts new file mode 100644 index 000000000..2598e7ac9 --- /dev/null +++ b/src/utils/__tests__/localValidate.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' +import { isValidKey, validateKey } from '../localValidate.js' + +describe('validateKey', () => { + test('rejects empty', () => { + expect(() => validateKey('')).toThrow(/empty/i) + }) + + test('rejects too long', () => { + expect(() => validateKey('a'.repeat(129))).toThrow(/too long/i) + }) + + test('rejects path separators', () => { + expect(() => validateKey('a/b')).toThrow(/invalid key chars/i) + expect(() => validateKey('a\\b')).toThrow(/invalid key chars/i) + }) + + test('rejects null byte', () => { + expect(() => validateKey('a\0b')).toThrow(/invalid key chars/i) + }) + + test('rejects spaces', () => { + expect(() => validateKey('a b')).toThrow(/invalid key chars/i) + }) + + test('rejects unicode', () => { + expect(() => validateKey('键名')).toThrow(/invalid key chars/i) + }) + + test('rejects leading dot', () => { + expect(() => validateKey('.gitconfig')).toThrow(/leading dot/i) + expect(() => validateKey('..parent')).toThrow(/leading dot/i) + expect(() => validateKey('.')).toThrow(/leading dot/i) + }) + + test('rejects Windows reserved names (case-insensitive)', () => { + for (const name of [ + 'NUL', + 'CON', + 'PRN', + 'AUX', + 'COM1', + 'COM9', + 'LPT1', + 'LPT9', + ]) { + expect(() => validateKey(name)).toThrow(/windows reserved/i) + expect(() => validateKey(name.toLowerCase())).toThrow(/windows reserved/i) + } + }) + + test('accepts valid keys', () => { + expect(() => validateKey('a')).not.toThrow() + expect(() => validateKey('a_b')).not.toThrow() + expect(() => validateKey('a-b')).not.toThrow() + expect(() => validateKey('a.b')).not.toThrow() + expect(() => validateKey('My_Key-2026.01')).not.toThrow() + expect(() => validateKey('a'.repeat(128))).not.toThrow() + }) + + test('M6: Windows reserved name with extension is REJECTED', () => { + // Windows aliases NUL.txt → NUL device regardless of extension. + expect(() => validateKey('NUL.txt')).toThrow(/windows reserved/i) + expect(() => validateKey('CON.foo')).toThrow(/windows reserved/i) + expect(() => validateKey('COM1.bak')).toThrow(/windows reserved/i) + expect(() => validateKey('lpt9.dat')).toThrow(/windows reserved/i) + }) + + test('Names containing reserved as substring are still allowed (myCON)', () => { + expect(() => validateKey('myCON')).not.toThrow() + expect(() => validateKey('CONfetti')).not.toThrow() + }) + + test('L2: bare ".." is rejected (leading-dot guard)', () => { + expect(() => validateKey('..')).toThrow(/leading dot/i) + }) +}) + +describe('isValidKey', () => { + test('returns true for valid keys', () => { + expect(isValidKey('a_b')).toBe(true) + }) + + test('returns false for invalid keys', () => { + expect(isValidKey('')).toBe(false) + expect(isValidKey('.git')).toBe(false) + expect(isValidKey('a/b')).toBe(false) + expect(isValidKey('NUL')).toBe(false) + }) +}) diff --git a/src/utils/localValidate.ts b/src/utils/localValidate.ts new file mode 100644 index 000000000..a149c8bdc --- /dev/null +++ b/src/utils/localValidate.ts @@ -0,0 +1,56 @@ +/** + * Shared validation utilities for /local-memory and /local-vault input names. + * + * Both LocalMemoryRecallTool (PR-1) and VaultHttpFetchTool (PR-2) need a + * consistent, path-safe, OS-portable key naming scheme. multiStore.ts also + * uses validateKey for entry keys after PR-0a key-collision fix. + * + * Allowed: letters, digits, dot, underscore, hyphen. + * Length 1..128. + * Rejected: + * - empty / too long + * - any character outside [A-Za-z0-9._-] + * - leading dot (hidden file pattern, e.g. ".gitconfig") + * - Windows reserved device names (NUL, CON, COM1, etc.) — would silently + * write to a device on Windows and lose data + */ + +const KEY_REGEX = /^[A-Za-z0-9._-]+$/ +// Windows treats device names as reserved REGARDLESS of extension — +// `NUL.txt`, `CON.foo`, `COM1.bak` all alias to the device. So we must +// match the basename component (everything before the first dot) against +// the reserved set, not just the entire key. +const WINDOWS_RESERVED_BASENAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i +const MAX_KEY_LENGTH = 128 + +export function validateKey(key: string): void { + if (!key) { + throw new Error('Empty key') + } + if (key.length > MAX_KEY_LENGTH) { + throw new Error(`Key too long (max ${MAX_KEY_LENGTH})`) + } + if (!KEY_REGEX.test(key)) { + throw new Error(`Invalid key chars: ${JSON.stringify(key)}`) + } + if (key.startsWith('.')) { + throw new Error('Leading dot forbidden') + } + // M6 fix: match the basename (pre-dot component) so e.g. NUL.txt and + // CON.foo are also rejected. On Windows these still alias to the device + // file regardless of extension and would silently lose data. + const basenameComponent = key.includes('.') ? key.split('.')[0]! : key + if (WINDOWS_RESERVED_BASENAME.test(basenameComponent)) { + throw new Error(`Windows reserved name: ${key}`) + } +} + +/** Returns true iff key would pass validateKey (no throw). Useful for guards. */ +export function isValidKey(key: string): boolean { + try { + validateKey(key) + return true + } catch { + return false + } +} diff --git a/src/utils/sanitizeId.ts b/src/utils/sanitizeId.ts new file mode 100644 index 000000000..be9844535 --- /dev/null +++ b/src/utils/sanitizeId.ts @@ -0,0 +1,14 @@ +/** + * Sanitize an ID for use in error messages. + * + * Security invariant: full IDs (vault_id, credential_id, agent_id, etc.) must + * not appear in error messages as they may be leaked into logs, bug reports, + * or user-facing text. Expose only the first 8 characters. + * + * H3: single source of truth extracted from the 4 P2 API client files + * (vaultsApi, agentsApi, memoryStoresApi, skillsApi). + */ +export function sanitizeId(id: string): string { + if (id.length <= 8) return id + return `${id.slice(0, 8)}…` +}