From 4f0aa8615a8ea4f30547542f5e1d062aa4b5777f Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 9 May 2026 23:04:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0=20?= =?UTF-8?q?Memory/Vault=20=E7=AE=A1=E7=90=86=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /local-memory: 本地记忆管理(store/entry CRUD、搜索、归档) - /local-vault: 本地密钥保险库管理(加解密、keychain 集成) - permissionValidation: vault 权限校验增强 Co-Authored-By: glm-5-turbo --- src/commands/local-memory/LocalMemoryView.tsx | 136 +++++ .../__tests__/launchLocalMemory.test.ts | 227 ++++++++ .../local-memory/__tests__/parseArgs.test.ts | 106 ++++ src/commands/local-memory/index.tsx | 22 + .../local-memory/launchLocalMemory.tsx | 527 ++++++++++++++++++ src/commands/local-memory/parseArgs.ts | 122 ++++ src/commands/local-vault/LocalVaultView.tsx | 107 ++++ .../__tests__/launchLocalVault.test.ts | 192 +++++++ .../local-vault/__tests__/parseArgs.test.ts | 146 +++++ src/commands/local-vault/index.tsx | 21 + src/commands/local-vault/launchLocalVault.tsx | 428 ++++++++++++++ src/commands/local-vault/parseArgs.ts | 116 ++++ .../permissionValidation-vault.test.ts | 246 ++++++++ src/utils/settings/permissionValidation.ts | 153 ++++- src/utils/settings/types.ts | 26 + src/utils/settings/validation.ts | 6 +- 16 files changed, 2577 insertions(+), 4 deletions(-) create mode 100644 src/commands/local-memory/LocalMemoryView.tsx create mode 100644 src/commands/local-memory/__tests__/launchLocalMemory.test.ts create mode 100644 src/commands/local-memory/__tests__/parseArgs.test.ts create mode 100644 src/commands/local-memory/index.tsx create mode 100644 src/commands/local-memory/launchLocalMemory.tsx create mode 100644 src/commands/local-memory/parseArgs.ts create mode 100644 src/commands/local-vault/LocalVaultView.tsx create mode 100644 src/commands/local-vault/__tests__/launchLocalVault.test.ts create mode 100644 src/commands/local-vault/__tests__/parseArgs.test.ts create mode 100644 src/commands/local-vault/index.tsx create mode 100644 src/commands/local-vault/launchLocalVault.tsx create mode 100644 src/commands/local-vault/parseArgs.ts create mode 100644 src/utils/settings/__tests__/permissionValidation-vault.test.ts diff --git a/src/commands/local-memory/LocalMemoryView.tsx b/src/commands/local-memory/LocalMemoryView.tsx new file mode 100644 index 000000000..cff0430b4 --- /dev/null +++ b/src/commands/local-memory/LocalMemoryView.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '@anthropic/ink'; + +export type LocalMemoryViewProps = + | { mode: 'list'; stores: string[] } + | { mode: 'created'; store: string } + | { mode: 'stored'; store: string; key: string } + | { mode: 'fetched'; store: string; key: string; value: string } + | { mode: 'not-found'; store: string; key?: string } + | { mode: 'entries'; store: string; keys: string[] } + | { mode: 'archived'; store: string } + | { mode: 'error'; message: string }; + +export function LocalMemoryView(props: LocalMemoryViewProps): React.ReactNode { + if (props.mode === 'list') { + if (props.stores.length === 0) { + return ( + + No memory stores found. Use /local-memory create <store> to create one. + + ); + } + return ( + + + Local Memory Stores ({props.stores.length}) + + {props.stores.map(s => ( + + + + {s} + + ))} + + ); + } + + if (props.mode === 'created') { + return ( + + + Store created: + {props.store} + + ); + } + + if (props.mode === 'stored') { + return ( + + + Stored entry + {props.key} + in + {props.store} + + ); + } + + if (props.mode === 'fetched') { + return ( + + + {props.store} + / + {props.key} + + + {props.value} + + + ); + } + + if (props.mode === 'not-found') { + return ( + + Not found: + {props.store} + {props.key ? ( + <> + / + {props.key} + + ) : null} + + ); + } + + if (props.mode === 'entries') { + if (props.keys.length === 0) { + return ( + + No entries in + {props.store} + . Use /local-memory store {props.store} <key> <value> to add one. + + ); + } + return ( + + + {props.store} + ({props.keys.length} entries) + + {props.keys.map(k => ( + + + · + {k} + + ))} + + ); + } + + if (props.mode === 'archived') { + return ( + + + Archived store: + {props.store} + (renamed to {props.store}.archived) + + ); + } + + // mode === 'error' + return ( + + Error: {props.message} + + ); +} diff --git a/src/commands/local-memory/__tests__/launchLocalMemory.test.ts b/src/commands/local-memory/__tests__/launchLocalMemory.test.ts new file mode 100644 index 000000000..c80e0637f --- /dev/null +++ b/src/commands/local-memory/__tests__/launchLocalMemory.test.ts @@ -0,0 +1,227 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// multiStore.ts has no log/debug/bun:bundle side effects — no mocks needed. + +let callLocalMemory: typeof import('../launchLocalMemory.js').callLocalMemory + +describe('callLocalMemory', () => { + let tmpDir: string + const messages: string[] = [] + const onDone = (msg?: string) => { + if (msg) messages.push(msg) + } + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'lm-launch-test-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + messages.length = 0 + const mod = await import('../launchLocalMemory.js') + callLocalMemory = mod.callLocalMemory + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + }) + + test('no args renders action panel without completing', async () => { + const node = await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + '', + ) + + expect(node).not.toBeNull() + expect(messages).toHaveLength(0) + }) + + test('list sub-command with no stores', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'list', + ) + expect( + messages.some(m => m.includes('No memory stores') || m.includes('0')), + ).toBe(true) + }) + + test('create sub-command creates a store', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create test-store', + ) + expect(messages.some(m => m.includes('test-store'))).toBe(true) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'list', + ) + expect(messages.some(m => m.includes('1') || m.includes('store'))).toBe( + true, + ) + }) + + test('store sub-command writes entry', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create notes', + ) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'store notes hello Hello World entry', + ) + expect(messages.some(m => m.includes('hello') || m.includes('notes'))).toBe( + true, + ) + }) + + test('fetch sub-command retrieves stored entry', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create fetch-store', + ) + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'store fetch-store mykey my entry value', + ) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'fetch fetch-store mykey', + ) + expect( + messages.some(m => m.includes('fetch-store') || m.includes('mykey')), + ).toBe(true) + expect(messages.join('\n')).toContain('my entry value') + }) + + test('fetch for nonexistent key → not-found', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create empty-s', + ) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'fetch empty-s nonexistent', + ) + expect( + messages.some(m => m.includes('not found') || m.includes('nonexistent')), + ).toBe(true) + }) + + test('entries sub-command lists keys in store', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create ent-store', + ) + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'store ent-store alpha value-a', + ) + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'store ent-store beta value-b', + ) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'entries ent-store', + ) + expect(messages.some(m => m.includes('2') || m.includes('ent-store'))).toBe( + true, + ) + const allMessages = messages.join('\n') + expect(allMessages).toContain('alpha') + expect(allMessages).toContain('beta') + }) + + test('archive sub-command archives a store', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create to-archive', + ) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'archive to-archive', + ) + expect( + messages.some(m => m.includes('to-archive') || m.includes('rchiv')), + ).toBe(true) + }) + + test('invalid sub-command shows usage', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'badcmd', + ) + expect( + messages.some( + m => m.toLowerCase().includes('usage') || m.includes('badcmd'), + ), + ).toBe(true) + }) + + test('create duplicate store → error view', async () => { + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create dup-store', + ) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'create dup-store', + ) + expect( + messages.some( + m => m.toLowerCase().includes('failed') || m.includes('already exists'), + ), + ).toBe(true) + }) + + test('store in nonexistent store auto-creates directory', async () => { + // No explicit create — setEntry should auto-create dir + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'store auto-create-store key1 value1', + ) + expect( + messages.some(m => m.includes('key1') || m.includes('auto-create-store')), + ).toBe(true) + messages.length = 0 + await callLocalMemory( + onDone as Parameters[0], + {} as Parameters[1], + 'fetch auto-create-store key1', + ) + expect( + messages.some(m => m.includes('auto-create-store') || m.includes('key1')), + ).toBe(true) + expect(messages.join('\n')).toContain('value1') + }) +}) diff --git a/src/commands/local-memory/__tests__/parseArgs.test.ts b/src/commands/local-memory/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..d63b0a660 --- /dev/null +++ b/src/commands/local-memory/__tests__/parseArgs.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from 'bun:test' +import { parseLocalMemoryArgs } from '../parseArgs.js' + +describe('parseLocalMemoryArgs', () => { + test('empty string → list', () => { + expect(parseLocalMemoryArgs('')).toEqual({ action: 'list' }) + }) + + test('"list" → list', () => { + expect(parseLocalMemoryArgs('list')).toEqual({ action: 'list' }) + }) + + test('create with store name', () => { + expect(parseLocalMemoryArgs('create my-store')).toEqual({ + action: 'create', + store: 'my-store', + }) + }) + + test('create without store name → invalid', () => { + expect(parseLocalMemoryArgs('create').action).toBe('invalid') + }) + + test('store with store, key, value', () => { + expect(parseLocalMemoryArgs('store my-store my-key my value here')).toEqual( + { + action: 'store', + store: 'my-store', + key: 'my-key', + value: 'my value here', + }, + ) + }) + + test('store without key → invalid', () => { + expect(parseLocalMemoryArgs('store my-store').action).toBe('invalid') + }) + + test('store without value → invalid', () => { + expect(parseLocalMemoryArgs('store my-store my-key').action).toBe('invalid') + }) + + test('fetch with store and key', () => { + expect(parseLocalMemoryArgs('fetch notes hello')).toEqual({ + action: 'fetch', + store: 'notes', + key: 'hello', + }) + }) + + test('fetch without key → invalid', () => { + expect(parseLocalMemoryArgs('fetch notes').action).toBe('invalid') + }) + + test('entries with store name', () => { + expect(parseLocalMemoryArgs('entries my-store')).toEqual({ + action: 'entries', + store: 'my-store', + }) + }) + + test('entries without store name → invalid', () => { + expect(parseLocalMemoryArgs('entries').action).toBe('invalid') + }) + + test('archive with store name', () => { + expect(parseLocalMemoryArgs('archive old-store')).toEqual({ + action: 'archive', + store: 'old-store', + }) + }) + + test('archive without store name → invalid', () => { + expect(parseLocalMemoryArgs('archive').action).toBe('invalid') + }) + + test('unknown sub-command → invalid with reason', () => { + const result = parseLocalMemoryArgs('frobnicate') + expect(result.action).toBe('invalid') + if (result.action === 'invalid') { + expect(result.reason).toContain('frobnicate') + } + }) + + test('"list" with trailing args still returns list action', () => { + // 'list extra' bypasses the short-circuit on line 33 and hits the + // tokens-based branch on line 41-43. + expect(parseLocalMemoryArgs('list extra-arg')).toEqual({ action: 'list' }) + }) + + test('store sub-command with no args → invalid (missing store name)', () => { + const r = parseLocalMemoryArgs('store') + expect(r.action).toBe('invalid') + if (r.action === 'invalid') { + expect(r.reason).toContain('store name') + } + }) + + test('fetch sub-command with no args → invalid (missing store name)', () => { + const r = parseLocalMemoryArgs('fetch') + expect(r.action).toBe('invalid') + if (r.action === 'invalid') { + expect(r.reason).toContain('store name') + } + }) +}) diff --git a/src/commands/local-memory/index.tsx b/src/commands/local-memory/index.tsx new file mode 100644 index 000000000..795813dba --- /dev/null +++ b/src/commands/local-memory/index.tsx @@ -0,0 +1,22 @@ +import type { Command } from '../../types/command.js'; + +const localMemoryCommand: Command = { + type: 'local-jsx', + name: 'local-memory', + aliases: ['lm'], + description: + 'Manage local memory stores for notes and context. Stored in ~/.claude/local-memory/ — no API key required.', + // Avoid `` / `` / `` in hint — REPL markdown renderer + // strips angle-bracketed words as HTML tags. Uppercase placeholders are + // visible. Same fix as /local-vault. + argumentHint: 'list | create STORE | store STORE KEY VALUE | fetch STORE KEY | entries STORE | archive STORE', + isHidden: false, + isEnabled: () => true, + bridgeSafe: true, + load: async () => { + const m = await import('./launchLocalMemory.js'); + return { call: m.callLocalMemory }; + }, +}; + +export default localMemoryCommand; diff --git a/src/commands/local-memory/launchLocalMemory.tsx b/src/commands/local-memory/launchLocalMemory.tsx new file mode 100644 index 000000000..2c8d5bcda --- /dev/null +++ b/src/commands/local-memory/launchLocalMemory.tsx @@ -0,0 +1,527 @@ +import React from 'react'; +import { Box, Dialog, Text, useInput } from '@anthropic/ink'; +import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js'; +import { + listStores, + createStore, + setEntry, + getEntry, + listEntries, + archiveStore, + isValidStoreName, +} from '../../services/SessionMemory/multiStore.js'; +import { isValidKey } from '../../utils/localValidate.js'; +import TextInput from '../../components/TextInput.js'; +import { LocalMemoryView } from './LocalMemoryView.js'; +import { parseLocalMemoryArgs } from './parseArgs.js'; +import { launchCommand } from '../_shared/launchCommand.js'; + +const USAGE = + 'Usage: /local-memory list | create STORE | store STORE KEY VALUE | fetch STORE KEY | entries STORE | archive STORE'; + +type LocalMemoryViewProps = React.ComponentProps; + +type LocalMemoryAction = { + label: string; + description: string; + run: () => void; +}; + +const ACTION_LABEL_COLUMN_WIDTH = 26; + +function formatStoreList(stores: string[]): string { + if (stores.length === 0) { + return 'No memory stores found.'; + } + return ['Local Memory Stores', ...stores.map(store => `- ${store}`)].join('\n'); +} + +function formatEntryList(store: string, keys: string[]): string { + if (keys.length === 0) { + return `No entries in "${store}".`; + } + return [`Entries in "${store}"`, ...keys.map(key => `- ${key}`)].join('\n'); +} + +// ── Interactive multi-step panel ─────────────────────────────────────────── +// State machine: +// menu — pick an action +// collect-store — input STORE_NAME (Create/Store/Fetch/Entries/Archive) +// collect-key — input KEY (Store/Fetch) +// collect-value — input VALUE (Store) +// confirm-archive — Y/N confirmation (Archive) +// confirm-overwrite — Y/N confirmation (Store when key exists) +// Each step has inline validation; Esc cancels back to menu (or closes from menu). + +type ActionKind = 'list' | 'create' | 'store' | 'fetch' | 'entries' | 'archive' | 'about'; + +type Step = + | { kind: 'menu' } + | { kind: 'collect-store'; action: ActionKind } + | { kind: 'collect-key'; action: ActionKind; store: string } + | { kind: 'collect-value'; action: ActionKind; store: string; key: string } + | { + kind: 'confirm-archive'; + store: string; + } + | { + kind: 'confirm-overwrite'; + store: string; + key: string; + value: string; + }; + +const MENU: Array<{ + kind: ActionKind; + label: string; + description: string; +}> = [ + { kind: 'list', label: 'List', description: 'Show all stores' }, + { + kind: 'create', + label: 'Create', + description: 'Create a new memory store', + }, + { + kind: 'store', + label: 'Store', + description: 'Write an entry: store name + key + value', + }, + { + kind: 'fetch', + label: 'Fetch', + description: 'Read an entry by store name + key', + }, + { + kind: 'entries', + label: 'Entries', + description: 'List entry keys in a store', + }, + { + kind: 'archive', + label: 'Archive', + description: 'Archive a store (rename to *.archived)', + }, + { + kind: 'about', + label: 'About', + description: 'Show command syntax', + }, +]; + +function LocalMemoryPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode { + const [step, setStep] = React.useState({ kind: 'menu' }); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [textValue, setTextValue] = React.useState(''); + const [cursorOffset, setCursorOffset] = React.useState(0); + const [error, setError] = React.useState(null); + + // Reset text/error when step transitions + const transition = React.useCallback((next: Step) => { + setStep(next); + setTextValue(''); + setCursorOffset(0); + setError(null); + }, []); + + const closeWith = React.useCallback((msg: string) => onDone(msg, { display: 'system' }), [onDone]); + + // Run an action when it has all required inputs. + const runAction = React.useCallback( + ( + action: ActionKind, + store: string | undefined, + key: string | undefined, + value: string | undefined, + opts: { confirmedOverwrite?: boolean } = {}, + ) => { + try { + if (action === 'list') { + closeWith(formatStoreList(listStores())); + return; + } + if (action === 'about') { + closeWith(USAGE); + return; + } + if (!store) { + setError('Internal: missing store'); + return; + } + if (action === 'create') { + createStore(store); + closeWith(`Store created: ${store}`); + return; + } + if (action === 'entries') { + const keys = listEntries(store); + closeWith(formatEntryList(store, keys)); + return; + } + if (action === 'archive') { + archiveStore(store); + closeWith(`Archived store: ${store}`); + return; + } + if (action === 'fetch') { + if (!key) { + setError('Internal: missing key'); + return; + } + const v = getEntry(store, key); + if (v === null) { + closeWith(`Entry not found: ${store}/${key}`); + return; + } + closeWith(`Entry fetched: ${store}/${key}\n\n${v}`); + return; + } + if (action === 'store') { + if (!key || value === undefined) { + setError('Internal: missing key or value'); + return; + } + // Confirm overwrite if key already exists (safety prompt) + if (!opts.confirmedOverwrite && getEntry(store, key) !== null) { + transition({ + kind: 'confirm-overwrite', + store, + key, + value, + }); + return; + } + setEntry(store, key, value); + closeWith(`Stored ${store}/${key} (${value.length} chars)`); + return; + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, + [closeWith, transition], + ); + + // ── Menu step ────────────────────────────────────────────────────────── + useInput( + (input, key) => { + if (step.kind !== 'menu') return; + if (key.upArrow) { + setSelectedIndex(idx => Math.max(0, idx - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex(idx => Math.min(MENU.length - 1, idx + 1)); + return; + } + if (key.return) { + const choice = MENU[selectedIndex]; + if (!choice) return; + if (choice.kind === 'list' || choice.kind === 'about') { + runAction(choice.kind, undefined, undefined, undefined); + return; + } + // Everything else needs a store + transition({ kind: 'collect-store', action: choice.kind }); + return; + } + // Quick-key shortcuts: 1..7 + const n = Number(input); + if (Number.isInteger(n) && n >= 1 && n <= MENU.length) { + setSelectedIndex(n - 1); + } + }, + { isActive: step.kind === 'menu' }, + ); + + // ── confirm-archive / confirm-overwrite Y/N handling ─────────────────── + useInput( + (input, key) => { + if (step.kind !== 'confirm-archive' && step.kind !== 'confirm-overwrite') { + return; + } + if (key.escape) { + transition({ kind: 'menu' }); + return; + } + const ch = input.toLowerCase(); + if (ch === 'y' || key.return) { + if (step.kind === 'confirm-archive') { + runAction('archive', step.store, undefined, undefined); + } else { + runAction('store', step.store, step.key, step.value, { + confirmedOverwrite: true, + }); + } + } else if (ch === 'n') { + transition({ kind: 'menu' }); + } + }, + { + isActive: step.kind === 'confirm-archive' || step.kind === 'confirm-overwrite', + }, + ); + + // Esc to back-step in collect-* steps + useInput( + (_input, key) => { + if (step.kind !== 'collect-store' && step.kind !== 'collect-key' && step.kind !== 'collect-value') { + return; + } + if (key.escape) { + // Walk back one step + if (step.kind === 'collect-value') { + transition({ + kind: 'collect-key', + action: step.action, + store: step.store, + }); + return; + } + if (step.kind === 'collect-key') { + transition({ kind: 'collect-store', action: step.action }); + return; + } + // collect-store → menu + transition({ kind: 'menu' }); + } + }, + { + isActive: step.kind === 'collect-store' || step.kind === 'collect-key' || step.kind === 'collect-value', + }, + ); + + // ── Render ────────────────────────────────────────────────────────────── + if (step.kind === 'menu') { + return ( + closeWith('Local memory panel dismissed')} + color="background" + hideInputGuide + > + + {MENU.map((m, i) => ( + + {`${i === selectedIndex ? '›' : ' '} ${m.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)} + {m.description} + + ))} + + ↑/↓ or 1-7 select · Enter run · Esc close + + + + ); + } + + // Confirmation prompts + if (step.kind === 'confirm-archive') { + return ( + transition({ kind: 'menu' })} color="warning" hideInputGuide> + + Archive store "{step.store}"? This renames it to *.archived. + + y/Enter = archive · n/Esc = cancel + + + + ); + } + if (step.kind === 'confirm-overwrite') { + return ( + transition({ kind: 'menu' })} color="warning" hideInputGuide> + + + Entry "{step.store}/{step.key}" already exists. Overwrite with new value ({step.value.length} chars)? + + + y/Enter = overwrite · n/Esc = cancel + + + + ); + } + + // collect-* steps share the same TextInput render + const fieldLabel = step.kind === 'collect-store' ? 'STORE NAME' : step.kind === 'collect-key' ? 'KEY NAME' : 'VALUE'; + const placeholder = + step.kind === 'collect-store' + ? 'e.g. my-notes' + : step.kind === 'collect-key' + ? 'e.g. todo-2026-05-08' + : 'free text'; + const validateAndAdvance = (raw: string) => { + const trimmed = raw.trim(); + if (step.kind === 'collect-store') { + if (!trimmed) { + setError('Store name required'); + return; + } + if (!isValidStoreName(trimmed)) { + setError('Invalid store name (no /, \\, :, null byte, or leading dot; max 255 chars)'); + return; + } + // Action-specific completion + if (step.action === 'create' || step.action === 'entries' || step.action === 'archive') { + if (step.action === 'archive') { + transition({ kind: 'confirm-archive', store: trimmed }); + } else { + runAction(step.action, trimmed, undefined, undefined); + } + } else { + // Store / Fetch — need key next + transition({ + kind: 'collect-key', + action: step.action, + store: trimmed, + }); + } + return; + } + if (step.kind === 'collect-key') { + if (!trimmed) { + setError('Key required'); + return; + } + if (!isValidKey(trimmed)) { + setError('Invalid key (allowed: letters/digits/._- only; no leading dot; not a Windows reserved name)'); + return; + } + if (step.action === 'fetch') { + runAction('fetch', step.store, trimmed, undefined); + } else { + // store action — collect value next + transition({ + kind: 'collect-value', + action: 'store', + store: step.store, + key: trimmed, + }); + } + return; + } + if (step.kind === 'collect-value') { + // Value can be empty (allowed). Just submit. + runAction('store', step.store, step.key, raw); + } + }; + + return ( + transition({ kind: 'menu' })} + color="background" + hideInputGuide + > + + + {fieldLabel} + + + {'> '} + { + setTextValue(v); + setError(null); + }} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + onSubmit={validateAndAdvance} + placeholder={placeholder} + columns={70} + showCursor + /> + + {error !== null && ( + + ✗ {error} + + )} + + Enter = next · Esc = back + + + + ); +} + +async function dispatchLocalMemory( + parsed: ReturnType, + onDone: LocalJSXCommandOnDone, +): Promise { + if (parsed.action === 'list') { + const stores = listStores(); + onDone(formatStoreList(stores), { display: 'system' }); + return null; + } + + if (parsed.action === 'create') { + const { store } = parsed; + createStore(store); + onDone(`Store created: ${store}`, { display: 'system' }); + return null; + } + + if (parsed.action === 'store') { + const { store, key, value } = parsed; + setEntry(store, key, value); + onDone(`Stored entry "${key}" in store "${store}".`, { display: 'system' }); + return null; + } + + if (parsed.action === 'fetch') { + const { store, key } = parsed; + const value = getEntry(store, key); + if (value === null) { + onDone(`Entry not found: ${store}/${key}`, { display: 'system' }); + return null; + } + onDone(`Entry fetched: ${store}/${key}\n${value}`, { display: 'system' }); + return null; + } + + if (parsed.action === 'entries') { + const { store } = parsed; + const keys = listEntries(store); + onDone(formatEntryList(store, keys), { display: 'system' }); + return null; + } + + if (parsed.action === 'archive') { + const { store } = parsed; + archiveStore(store); + onDone(`Archived store: ${store}`, { display: 'system' }); + return null; + } + + // Exhaustive guard + onDone(USAGE, { display: 'system' }); + return null; +} + +const callLocalMemoryDirect: LocalJSXCommandCall = launchCommand< + ReturnType, + LocalMemoryViewProps +>({ + commandName: 'local-memory', + parseArgs: (raw: string) => { + const result = parseLocalMemoryArgs(raw); + if (result.action === 'invalid') { + return { action: 'invalid' as const, reason: `${USAGE}\n${result.reason}` }; + } + return result; + }, + dispatch: dispatchLocalMemory, + View: LocalMemoryView, + errorView: (msg: string) => React.createElement(LocalMemoryView, { mode: 'error', message: msg }), +}); + +export const callLocalMemory: LocalJSXCommandCall = async (onDone, context, args) => { + if ((args ?? '').trim() === '') { + return ; + } + return callLocalMemoryDirect(onDone, context, args); +}; diff --git a/src/commands/local-memory/parseArgs.ts b/src/commands/local-memory/parseArgs.ts new file mode 100644 index 000000000..510e836ac --- /dev/null +++ b/src/commands/local-memory/parseArgs.ts @@ -0,0 +1,122 @@ +/** + * Parse the args string for the /local-memory command. + * + * Supported sub-commands: + * list → { action: 'list' } + * create → { action: 'create', store } + * store → { action: 'store', store, key, value } + * fetch → { action: 'fetch', store, key } + * entries → { action: 'entries', store } + * archive → { action: 'archive', store } + * (empty) → { action: 'list' } + * anything else → { action: 'invalid', reason } + */ + +export type LocalMemoryArgs = + | { action: 'list' } + | { action: 'create'; store: string } + | { action: 'store'; store: string; key: string; value: string } + | { action: 'fetch'; store: string; key: string } + | { action: 'entries'; store: string } + | { action: 'archive'; store: string } + | { action: 'invalid'; reason: string } + +// Markdown renderer in REPL eats `` / `` / `` as if +// they were HTML tags. Use uppercase placeholders so users see the +// full usage line. (Same fix as src/commands/local-vault/parseArgs.ts.) +const USAGE = + 'Usage: /local-memory list | create STORE | store STORE KEY VALUE | fetch STORE KEY | entries STORE | archive STORE' + +export function parseLocalMemoryArgs(args: string): LocalMemoryArgs { + const trimmed = args.trim() + + if (trimmed === '' || trimmed === 'list') { + return { action: 'list' } + } + + const tokens = trimmed.split(/\s+/) + const subCmd = tokens[0] + + // ── list ────────────────────────────────────────────────────────────────── + if (subCmd === 'list') { + return { action: 'list' } + } + + // ── create ──────────────────────────────────────────────────────────────── + if (subCmd === 'create') { + const store = tokens[1] + if (!store) { + return { + action: 'invalid', + reason: `create requires a store name. ${USAGE}`, + } + } + return { action: 'create', store } + } + + // ── store ───────────────────────────────────────────────────────────────── + if (subCmd === 'store') { + const store = tokens[1] + const key = tokens[2] + if (!store) { + return { + action: 'invalid', + reason: `store requires a store name. ${USAGE}`, + } + } + if (!key) { + return { action: 'invalid', reason: `store requires a key. ${USAGE}` } + } + // D6: value is tokens[3..] joined, not substring math (handles store/key with repeated substrings) + const rest = tokens.slice(3).join(' ') + if (!rest) { + return { action: 'invalid', reason: `store requires a value. ${USAGE}` } + } + return { action: 'store', store, key, value: rest } + } + + // ── fetch ───────────────────────────────────────────────────────────────── + if (subCmd === 'fetch') { + const store = tokens[1] + const key = tokens[2] + if (!store) { + return { + action: 'invalid', + reason: `fetch requires a store name. ${USAGE}`, + } + } + if (!key) { + return { action: 'invalid', reason: `fetch requires a key. ${USAGE}` } + } + return { action: 'fetch', store, key } + } + + // ── entries ─────────────────────────────────────────────────────────────── + if (subCmd === 'entries') { + const store = tokens[1] + if (!store) { + return { + action: 'invalid', + reason: `entries requires a store name. ${USAGE}`, + } + } + return { action: 'entries', store } + } + + // ── archive ─────────────────────────────────────────────────────────────── + if (subCmd === 'archive') { + const store = tokens[1] + if (!store) { + return { + action: 'invalid', + reason: `archive requires a store name. ${USAGE}`, + } + } + return { action: 'archive', store } + } + + return { + action: 'invalid', + reason: `Unknown sub-command "${subCmd}". ${USAGE}`, + } +} diff --git a/src/commands/local-vault/LocalVaultView.tsx b/src/commands/local-vault/LocalVaultView.tsx new file mode 100644 index 000000000..42b41d93a --- /dev/null +++ b/src/commands/local-vault/LocalVaultView.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '@anthropic/ink'; + +export type LocalVaultViewProps = + | { mode: 'list'; keys: string[] } + | { mode: 'set-ok'; key: string } + | { mode: 'get-masked'; key: string; masked: string } + | { mode: 'get-revealed'; key: string; value: string } + | { mode: 'not-found'; key: string } + | { mode: 'deleted'; key: string } + | { mode: 'error'; message: string }; + +export function LocalVaultView(props: LocalVaultViewProps): React.ReactNode { + if (props.mode === 'list') { + if (props.keys.length === 0) { + return ( + + No secrets stored. Use /local-vault set <key> <value> to add one. + + ); + } + return ( + + + Local Vault Keys ({props.keys.length}) + + {props.keys.map(k => ( + + + + {k} + + ))} + + ); + } + + if (props.mode === 'set-ok') { + return ( + + + Secret stored: + {props.key} + = [REDACTED] + + ); + } + + if (props.mode === 'get-masked') { + return ( + + + {props.key} + : + {props.masked} + + + Use /local-vault get {props.key} --reveal to see the full value. + + + ); + } + + if (props.mode === 'get-revealed') { + return ( + + + {props.key} + : + {props.value} + + + + ⚠ Secret revealed in terminal — clear scrollback if this session is shared. + + + + ); + } + + if (props.mode === 'not-found') { + return ( + + Key not found: + {props.key} + + ); + } + + if (props.mode === 'deleted') { + return ( + + + Deleted: + {props.key} + + ); + } + + // mode === 'error' + return ( + + Error: {props.message} + + ); +} diff --git a/src/commands/local-vault/__tests__/launchLocalVault.test.ts b/src/commands/local-vault/__tests__/launchLocalVault.test.ts new file mode 100644 index 000000000..5d89b2f12 --- /dev/null +++ b/src/commands/local-vault/__tests__/launchLocalVault.test.ts @@ -0,0 +1,192 @@ +import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync } 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 })) + +// No keychain mock here — the real store falls back to encrypted file when +// @napi-rs/keyring is not installed (which it is not in this environment). +// This exercises the full file-fallback path without cross-test module pollution. + +let callLocalVault: typeof import('../launchLocalVault.js').callLocalVault + +describe('callLocalVault', () => { + let tmpDir: string + const messages: string[] = [] + const onDone = (msg?: string) => { + if (msg) messages.push(msg) + } + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'lv-launch-test-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] = + 'test-passphrase-fixed-32chars-xxx' + messages.length = 0 + const mod = await import('../launchLocalVault.js') + callLocalVault = mod.callLocalVault + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] + }) + + test('no args renders action panel without completing', async () => { + const node = await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + '', + ) + + expect(node).not.toBeNull() + expect(messages).toHaveLength(0) + }) + + test('list sub-command shows key count', async () => { + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'list', + ) + expect(messages.some(m => m.includes('0') || m.includes('secret'))).toBe( + true, + ) + }) + + test('set sub-command stores secret; onDone contains [REDACTED], not value', async () => { + const secretValue = 'SUPER_SENSITIVE_VALUE_XYZ_789' + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + `set MY_API_KEY ${secretValue}`, + ) + // Security invariant: value must NOT appear in any message + for (const msg of messages) { + expect(msg).not.toContain(secretValue) + } + expect(messages.some(m => m.includes('[REDACTED]'))).toBe(true) + }) + + test('get sub-command shows masked value by default', async () => { + const secretValue = 'ABCDEFGHIJ1234567890' + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + `set KEY_MASK ${secretValue}`, + ) + messages.length = 0 + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'get KEY_MASK', + ) + // Masked: should contain "..." but NOT the full value + const allMessages = messages.join('\n') + expect(allMessages).toContain('...') + // Security invariant: full secret should NOT appear in masked messages + expect(allMessages).not.toContain(secretValue) + }) + + test('get --reveal shows plaintext value', async () => { + const secretValue = 'REVEAL_TEST_VALUE_9988' + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + `set REVEAL_KEY ${secretValue}`, + ) + messages.length = 0 + const node = await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'get REVEAL_KEY --reveal', + ) + expect(messages.some(m => m.includes('REVEAL_KEY'))).toBe(true) + const allMessages = messages.join('\n') + expect(allMessages).toContain(secretValue) + expect(allMessages).toContain('Warning') + expect(node).toBeNull() + }) + + test('get without --reveal does NOT expose full secret in onDone messages', async () => { + const secretValue = 'MUST_NOT_APPEAR_IN_MESSAGES_ZZZZ' + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + `set MASK_CHECK ${secretValue}`, + ) + messages.length = 0 + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'get MASK_CHECK', + ) + for (const msg of messages) { + expect(msg).not.toContain(secretValue) + } + }) + + test('get for nonexistent key → not-found view', async () => { + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'get GHOST_KEY', + ) + expect( + messages.some(m => m.includes('not found') || m.includes('GHOST_KEY')), + ).toBe(true) + }) + + test('delete sub-command removes key', async () => { + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'set TO_DEL_KEY some-value', + ) + messages.length = 0 + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'delete TO_DEL_KEY', + ) + expect( + messages.some(m => m.includes('Deleted') || m.includes('TO_DEL_KEY')), + ).toBe(true) + }) + + test('invalid sub-command shows usage', async () => { + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'frobnicate MY_KEY', + ) + expect( + messages.some( + m => m.toLowerCase().includes('usage') || m.includes('frobnicate'), + ), + ).toBe(true) + }) + + test('reveal flag safety invariant: masked path never exposes full value in messages', async () => { + const secret = 'INVARIANT_TEST_123456789ABC' + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + `set INV_KEY ${secret}`, + ) + messages.length = 0 + // Without --reveal + await callLocalVault( + onDone as Parameters[0], + {} as Parameters[1], + 'get INV_KEY', + ) + for (const msg of messages) { + expect(msg).not.toContain(secret) + } + }) +}) diff --git a/src/commands/local-vault/__tests__/parseArgs.test.ts b/src/commands/local-vault/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..1075bbd3a --- /dev/null +++ b/src/commands/local-vault/__tests__/parseArgs.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect } from 'bun:test' +import { parseLocalVaultArgs } from '../parseArgs.js' + +describe('parseLocalVaultArgs', () => { + test('empty string → list', () => { + expect(parseLocalVaultArgs('')).toEqual({ action: 'list' }) + }) + + test('"list" → list', () => { + expect(parseLocalVaultArgs('list')).toEqual({ action: 'list' }) + }) + + test('set with key and value', () => { + expect(parseLocalVaultArgs('set MY_KEY my-secret-value')).toEqual({ + action: 'set', + key: 'MY_KEY', + value: 'my-secret-value', + }) + }) + + test('set with value containing spaces', () => { + expect(parseLocalVaultArgs('set MY_KEY value with spaces')).toEqual({ + action: 'set', + key: 'MY_KEY', + value: 'value with spaces', + }) + }) + + test('set without value → invalid', () => { + const result = parseLocalVaultArgs('set MY_KEY') + expect(result.action).toBe('invalid') + }) + + test('set without key → invalid', () => { + const result = parseLocalVaultArgs('set') + expect(result.action).toBe('invalid') + }) + + test('get without --reveal → reveal=false', () => { + expect(parseLocalVaultArgs('get MY_KEY')).toEqual({ + action: 'get', + key: 'MY_KEY', + reveal: false, + }) + }) + + test('get with --reveal → reveal=true', () => { + expect(parseLocalVaultArgs('get MY_KEY --reveal')).toEqual({ + action: 'get', + key: 'MY_KEY', + reveal: true, + }) + }) + + test('get without key → invalid', () => { + const result = parseLocalVaultArgs('get') + expect(result.action).toBe('invalid') + }) + + test('delete with key', () => { + expect(parseLocalVaultArgs('delete MY_KEY')).toEqual({ + action: 'delete', + key: 'MY_KEY', + }) + }) + + test('delete without key → invalid', () => { + const result = parseLocalVaultArgs('delete') + expect(result.action).toBe('invalid') + }) + + test('unknown sub-command → invalid', () => { + const result = parseLocalVaultArgs('frobnicate') + expect(result.action).toBe('invalid') + if (result.action === 'invalid') { + expect(result.reason).toContain('frobnicate') + } + }) + + test('"list" with trailing args still returns list action', () => { + expect(parseLocalVaultArgs('list extra-arg')).toEqual({ action: 'list' }) + }) + + test('set with key starting with "-" → invalid (reserved for flags)', () => { + const r = parseLocalVaultArgs('set --some-flag value') + expect(r.action).toBe('invalid') + if (r.action === 'invalid') { + expect(r.reason.toLowerCase()).toContain('flag') + } + }) + + test('set with key starting with single "-" → invalid', () => { + const r = parseLocalVaultArgs('set -k v') + expect(r.action).toBe('invalid') + }) + + // ── M1 (codecov-100 audit #4): hyphen-like Unicode prefix rejection ── + // U+2212 MINUS SIGN visually looks like '-' but the shell would not + // round-trip it back to ASCII '-'. If we accepted such keys, the user + // could store them but never retrieve them via the CLI. + describe('M1: hyphen-like Unicode prefix rejection (audit #4)', () => { + test('U+2212 MINUS SIGN prefix → invalid', () => { + const r = parseLocalVaultArgs('set −key value') + expect(r.action).toBe('invalid') + if (r.action === 'invalid') { + expect(r.reason.toLowerCase()).toContain('hyphen') + } + }) + + test('U+2010 HYPHEN prefix → invalid', () => { + const r = parseLocalVaultArgs('set ‐key value') + expect(r.action).toBe('invalid') + }) + + test('U+2013 EN DASH prefix → invalid', () => { + const r = parseLocalVaultArgs('set –key value') + expect(r.action).toBe('invalid') + }) + + test('U+2014 EM DASH prefix → invalid', () => { + const r = parseLocalVaultArgs('set —key value') + expect(r.action).toBe('invalid') + }) + + test('U+FF0D FULLWIDTH HYPHEN-MINUS prefix → invalid', () => { + const r = parseLocalVaultArgs('set -key value') + expect(r.action).toBe('invalid') + }) + + test('non-hyphen unicode prefix is still allowed (e.g. CJK)', () => { + // Defensive: we only reject hyphen-like; legitimate unicode keys + // like '日本語' must still be accepted. + const r = parseLocalVaultArgs('set 日本語key value') + expect(r.action).toBe('set') + if (r.action === 'set') { + expect(r.key).toBe('日本語key') + expect(r.value).toBe('value') + } + }) + + test('underscore prefix is still allowed (not a hyphen)', () => { + const r = parseLocalVaultArgs('set _under value') + expect(r.action).toBe('set') + }) + }) +}) diff --git a/src/commands/local-vault/index.tsx b/src/commands/local-vault/index.tsx new file mode 100644 index 000000000..820542827 --- /dev/null +++ b/src/commands/local-vault/index.tsx @@ -0,0 +1,21 @@ +import type { Command } from '../../types/command.js'; + +const localVaultCommand: Command = { + type: 'local-jsx', + name: 'local-vault', + aliases: ['lv', 'local-secret'], + description: + 'Manage local encrypted secrets. Stored in OS keychain or encrypted file fallback — no API key required.', + // Avoid `` / `` in the hint — REPL markdown renderer eats angle- + // bracketed words as HTML tags. Uppercase placeholders survive intact. + argumentHint: 'list | set KEY VALUE | get KEY [--reveal] | delete KEY', + isHidden: false, + isEnabled: () => true, + bridgeSafe: true, + load: async () => { + const m = await import('./launchLocalVault.js'); + return { call: m.callLocalVault }; + }, +}; + +export default localVaultCommand; diff --git a/src/commands/local-vault/launchLocalVault.tsx b/src/commands/local-vault/launchLocalVault.tsx new file mode 100644 index 000000000..a90b6756b --- /dev/null +++ b/src/commands/local-vault/launchLocalVault.tsx @@ -0,0 +1,428 @@ +import React from 'react'; +import { Box, Dialog, Text, useInput } from '@anthropic/ink'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +import { setSecret, getSecret, deleteSecret, listKeys, maskSecret } from '../../services/localVault/store.js'; +import { isValidKey } from '../../utils/localValidate.js'; +import TextInput from '../../components/TextInput.js'; +import { LocalVaultView } from './LocalVaultView.js'; +import { parseLocalVaultArgs } from './parseArgs.js'; +import { launchCommand } from '../_shared/launchCommand.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; + +const USAGE = 'Usage: /local-vault list | set KEY VALUE | get KEY [--reveal] | delete KEY'; + +type LocalVaultViewProps = React.ComponentProps; + +type LocalVaultAction = { + label: string; + description: string; + run: () => void; +}; + +const ACTION_LABEL_COLUMN_WIDTH = 26; + +function formatKeyList(keys: string[]): string { + if (keys.length === 0) { + return 'No secrets stored.'; + } + return ['Local Vault Keys', ...keys.map(key => `- ${key}`)].join('\n'); +} + +// ── Interactive multi-step panel ─────────────────────────────────────────── +// Vault state machine: +// menu — pick action +// collect-key — KEY name (Set/Get/Delete) +// collect-value — secret VALUE (Set only; masked input) +// confirm-overwrite — Y/N when key exists (Set) +// confirm-delete — Y/N (Delete) + +type VaultActionKind = 'list' | 'set' | 'get' | 'delete' | 'about'; + +type VaultStep = + | { kind: 'menu' } + | { kind: 'collect-key'; action: VaultActionKind } + | { kind: 'collect-value'; key: string } + | { kind: 'confirm-overwrite'; key: string; value: string } + | { kind: 'confirm-delete'; key: string }; + +const VAULT_MENU: Array<{ + kind: VaultActionKind; + label: string; + description: string; +}> = [ + { kind: 'list', label: 'List', description: 'Show stored secret keys' }, + { + kind: 'set', + label: 'Set', + description: 'Store a secret: KEY + VALUE (input is masked)', + }, + { + kind: 'get', + label: 'Get', + description: 'Look up a secret (returns masked preview)', + }, + { + kind: 'delete', + label: 'Delete', + description: 'Delete a stored secret by KEY', + }, + { + kind: 'about', + label: 'About', + description: 'Show command syntax', + }, +]; + +function LocalVaultPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode { + const [step, setStep] = React.useState({ kind: 'menu' }); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [textValue, setTextValue] = React.useState(''); + const [cursorOffset, setCursorOffset] = React.useState(0); + const [error, setError] = React.useState(null); + const [inFlight, setInFlight] = React.useState(false); + + const transition = React.useCallback((next: VaultStep) => { + setStep(next); + setTextValue(''); + setCursorOffset(0); + setError(null); + }, []); + + const closeWith = React.useCallback((msg: string) => onDone(msg, { display: 'system' }), [onDone]); + + // ── Menu navigation ──────────────────────────────────────────────────── + useInput( + (input, key) => { + if (step.kind !== 'menu' || inFlight) return; + if (key.upArrow) { + setSelectedIndex(idx => Math.max(0, idx - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex(idx => Math.min(VAULT_MENU.length - 1, idx + 1)); + return; + } + if (key.return) { + const choice = VAULT_MENU[selectedIndex]; + if (!choice) return; + if (choice.kind === 'about') { + closeWith(USAGE); + return; + } + if (choice.kind === 'list') { + setInFlight(true); + void listKeys().then(keys => { + closeWith(formatKeyList(keys)); + }); + return; + } + // Set / Get / Delete — collect key first + transition({ kind: 'collect-key', action: choice.kind }); + return; + } + const n = Number(input); + if (Number.isInteger(n) && n >= 1 && n <= VAULT_MENU.length) { + setSelectedIndex(n - 1); + } + }, + { isActive: step.kind === 'menu' && !inFlight }, + ); + + // ── Confirmations (overwrite / delete) ───────────────────────────────── + useInput( + (input, key) => { + if (step.kind !== 'confirm-overwrite' && step.kind !== 'confirm-delete') { + return; + } + if (key.escape) { + transition({ kind: 'menu' }); + return; + } + const ch = input.toLowerCase(); + if (ch === 'y' || key.return) { + if (step.kind === 'confirm-delete') { + setInFlight(true); + const key = step.key; + void deleteSecret(key).then(removed => { + closeWith(removed ? `Deleted: ${key}` : `Key not found: ${key}`); + }); + } else { + // confirm-overwrite — proceed with setSecret + setInFlight(true); + const k = step.key; + const v = step.value; + void setSecret(k, v) + .then(() => closeWith(`Secret stored: ${k} = [REDACTED]`)) + .catch(e => closeWith(`Failed to store ${k}: ${e instanceof Error ? e.message : String(e)}`)); + } + } else if (ch === 'n') { + transition({ kind: 'menu' }); + } + }, + { + isActive: (step.kind === 'confirm-overwrite' || step.kind === 'confirm-delete') && !inFlight, + }, + ); + + // Esc back-step in collect-* steps + useInput( + (_input, key) => { + if (step.kind !== 'collect-key' && step.kind !== 'collect-value') return; + if (key.escape) { + if (step.kind === 'collect-value') { + transition({ kind: 'collect-key', action: 'set' }); + return; + } + transition({ kind: 'menu' }); + } + }, + { + isActive: (step.kind === 'collect-key' || step.kind === 'collect-value') && !inFlight, + }, + ); + + // ── Action handlers ───────────────────────────────────────────────────── + const handleKeySubmit = (raw: string) => { + const key = raw.trim(); + if (!key) { + setError('Key required'); + return; + } + if (!isValidKey(key)) { + setError('Invalid key (allowed: letters/digits/._- only; no leading dot; not a Windows reserved name)'); + return; + } + if (step.kind !== 'collect-key') return; + if (step.action === 'get') { + setInFlight(true); + void getSecret(key).then(v => { + if (v === null) { + closeWith(`Key not found: ${key}`); + } else { + closeWith(`Key found: ${key} = ${maskSecret(v)}`); + } + }); + return; + } + if (step.action === 'delete') { + transition({ kind: 'confirm-delete', key }); + return; + } + if (step.action === 'set') { + transition({ kind: 'collect-value', key }); + return; + } + }; + + const handleValueSubmit = (rawValue: string) => { + if (step.kind !== 'collect-value') return; + if (rawValue.length === 0) { + setError('Secret value cannot be empty'); + return; + } + const k = step.key; + // Check overwrite + setInFlight(true); + void getSecret(k) + .then(existing => { + if (existing !== null) { + // Need confirmation + setInFlight(false); + transition({ + kind: 'confirm-overwrite', + key: k, + value: rawValue, + }); + return; + } + return setSecret(k, rawValue).then(() => closeWith(`Secret stored: ${k} = [REDACTED]`)); + }) + .catch(e => closeWith(`Failed to store ${k}: ${e instanceof Error ? e.message : String(e)}`)); + }; + + // ── Render ────────────────────────────────────────────────────────────── + if (step.kind === 'menu') { + return ( + closeWith('Local vault panel dismissed')} + color="background" + hideInputGuide + > + + {VAULT_MENU.map((m, i) => ( + + {`${i === selectedIndex ? '›' : ' '} ${m.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)} + {m.description} + + ))} + {inFlight && ( + + Working... + + )} + + ↑/↓ or 1-5 select · Enter run · Esc close + + + + ); + } + + if (step.kind === 'confirm-delete') { + return ( + transition({ kind: 'menu' })} color="warning" hideInputGuide> + + Delete secret "{step.key}"? This cannot be undone. + + y/Enter = delete · n/Esc = cancel + + {inFlight && Deleting...} + + + ); + } + + if (step.kind === 'confirm-overwrite') { + return ( + transition({ kind: 'menu' })} color="warning" hideInputGuide> + + Secret "{step.key}" already exists. Overwrite? Old value is lost. + + y/Enter = overwrite · n/Esc = cancel + + {inFlight && Storing...} + + + ); + } + + // collect-key / collect-value + const fieldLabel = step.kind === 'collect-key' ? 'KEY NAME' : 'SECRET VALUE'; + const placeholder = step.kind === 'collect-key' ? 'e.g. github-token' : '(masked input — value never displayed)'; + const onSubmit = step.kind === 'collect-key' ? handleKeySubmit : handleValueSubmit; + const isMasked = step.kind === 'collect-value'; + return ( + transition({ kind: 'menu' })} + color="background" + hideInputGuide + > + + + {fieldLabel} + + + {'> '} + { + setTextValue(v); + setError(null); + }} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + onSubmit={onSubmit} + placeholder={placeholder} + columns={70} + showCursor + mask={isMasked ? '*' : undefined} + /> + + {error !== null && ( + + ✗ {error} + + )} + {inFlight && ( + + Working... + + )} + + Enter = next · Esc = back + + + + ); +} + +async function dispatchLocalVault( + parsed: ReturnType, + onDone: LocalJSXCommandOnDone, +): Promise { + if (parsed.action === 'list') { + const keys = await listKeys(); + onDone(formatKeyList(keys), { display: 'system' }); + return null; + } + + if (parsed.action === 'set') { + const { key, value } = parsed; + await setSecret(key, value); + // Never echo the value in onDone — security invariant + onDone(`Secret stored: ${key} = [REDACTED]`, { display: 'system' }); + return null; + } + + if (parsed.action === 'get') { + const { key, reveal } = parsed; + const value = await getSecret(key); + if (value === null) { + onDone(`Key not found: ${key}`, { display: 'system' }); + return null; + } + if (reveal) { + // Security invariant: only --reveal shows plaintext; warn user + onDone([`Secret revealed for: ${key}`, 'Warning: secret revealed in terminal.', `${key} = ${value}`].join('\n'), { + display: 'system', + }); + return null; + } + // Default: mask display + const masked = maskSecret(value); + onDone(`Key found: ${key} = ${masked}`, { display: 'system' }); + return null; + } + + if (parsed.action === 'delete') { + const { key } = parsed; + const deleted = await deleteSecret(key); + if (!deleted) { + onDone(`Key not found: ${key}`, { display: 'system' }); + return null; + } + onDone(`Deleted: ${key}`, { display: 'system' }); + return null; + } + + // Exhaustive guard — should not be reached for valid parsed actions + onDone(USAGE, { display: 'system' }); + return null; +} + +const callLocalVaultDirect: LocalJSXCommandCall = launchCommand< + ReturnType, + LocalVaultViewProps +>({ + commandName: 'local-vault', + parseArgs: (raw: string) => { + const result = parseLocalVaultArgs(raw); + if (result.action === 'invalid') { + return { action: 'invalid' as const, reason: `${USAGE}\n${result.reason}` }; + } + return result; + }, + dispatch: dispatchLocalVault, + View: LocalVaultView, + errorView: (msg: string) => React.createElement(LocalVaultView, { mode: 'error', message: msg }), +}); + +export const callLocalVault: LocalJSXCommandCall = async (onDone, context, args) => { + if ((args ?? '').trim() === '') { + return ; + } + return callLocalVaultDirect(onDone, context, args); +}; diff --git a/src/commands/local-vault/parseArgs.ts b/src/commands/local-vault/parseArgs.ts new file mode 100644 index 000000000..e76066ece --- /dev/null +++ b/src/commands/local-vault/parseArgs.ts @@ -0,0 +1,116 @@ +/** + * Parse the args string for the /local-vault command. + * + * Supported sub-commands: + * list → { action: 'list' } + * set → { action: 'set', key, value } + * get → { action: 'get', key, reveal: false } + * get --reveal → { action: 'get', key, reveal: true } + * delete → { action: 'delete', key } + * (empty) → { action: 'list' } + * anything else → { action: 'invalid', reason } + */ + +export type LocalVaultArgs = + | { action: 'list' } + | { action: 'set'; key: string; value: string } + | { action: 'get'; key: string; reveal: boolean } + | { action: 'delete'; key: string } + | { action: 'invalid'; reason: string } + +// Markdown renderer in REPL output treats `` / `` as HTML tags +// and strips them. Use uppercase placeholder names without angle brackets +// so the full usage line is visible to users. +const USAGE = + 'Usage: /local-vault list | set KEY VALUE | get KEY [--reveal] | delete KEY' + +// M1 fix (codecov-100 audit #4): defensively reject hyphen-like Unicode +// prefixes on key names. ASCII '-' is the obvious flag prefix, but a key +// stored as e.g. '−mykey' (U+2212 MINUS SIGN) would round-trip through +// /local-vault set and then be unretrievable via the CLI because the +// shell-style tokenizer here is consistent. Reject any key whose first +// character is in the Unicode hyphen / dash family. List drawn from +// Unicode general category Pd (Dash_Punctuation) plus the math minus. +// U+002D HYPHEN-MINUS - +// U+2010 HYPHEN ‐ +// U+2011 NON-BREAKING HYPHEN ‑ +// U+2012 FIGURE DASH ‒ +// U+2013 EN DASH – +// U+2014 EM DASH — +// U+2015 HORIZONTAL BAR ― +// U+2212 MINUS SIGN − +// U+FE58 SMALL EM DASH ﹘ +// U+FE63 SMALL HYPHEN-MINUS ﹣ +// U+FF0D FULLWIDTH HYPHEN-MINUS - +const HYPHEN_LIKE_PREFIX_REGEX = /^[-‐-―−﹘﹣-]/ + +export function parseLocalVaultArgs(args: string): LocalVaultArgs { + const trimmed = args.trim() + + if (trimmed === '' || trimmed === 'list') { + return { action: 'list' } + } + + const tokens = trimmed.split(/\s+/) + const subCmd = tokens[0] + + // ── list ────────────────────────────────────────────────────────────────── + if (subCmd === 'list') { + return { action: 'list' } + } + + // ── set ─────────────────────────────────────────────────────────────────── + if (subCmd === 'set') { + const key = tokens[1] + if (!key) { + return { action: 'invalid', reason: `set requires a key name. ${USAGE}` } + } + // D3 + M1: reject keys that start with '-' or any hyphen-like Unicode + // character. ASCII '-' would be mistaken for a flag; non-ASCII hyphen + // lookalikes (e.g. U+2212 MINUS SIGN) would silently store but then be + // unretrievable because the user typically can't reproduce the exact + // codepoint at the shell. + if (HYPHEN_LIKE_PREFIX_REGEX.test(key)) { + return { + action: 'invalid', + reason: `Key name must not start with "-" or a hyphen-like character (reserved for flags). ${USAGE}`, + } + } + // D4: value is tokens[2..] joined, not substring math (handles keys with repeated substrings) + const rest = tokens.slice(2).join(' ') + if (!rest) { + return { + action: 'invalid', + reason: `set requires a value. ${USAGE}`, + } + } + return { action: 'set', key, value: rest } + } + + // ── get ─────────────────────────────────────────────────────────────────── + if (subCmd === 'get') { + const key = tokens[1] + if (!key) { + return { action: 'invalid', reason: `get requires a key name. ${USAGE}` } + } + const reveal = tokens.includes('--reveal') + return { action: 'get', key, reveal } + } + + // ── delete ──────────────────────────────────────────────────────────────── + if (subCmd === 'delete') { + const key = tokens[1] + if (!key) { + return { + action: 'invalid', + reason: `delete requires a key name. ${USAGE}`, + } + } + return { action: 'delete', key } + } + + return { + action: 'invalid', + reason: `Unknown sub-command "${subCmd}". ${USAGE}`, + } +} diff --git a/src/utils/settings/__tests__/permissionValidation-vault.test.ts b/src/utils/settings/__tests__/permissionValidation-vault.test.ts new file mode 100644 index 000000000..240e42ee1 --- /dev/null +++ b/src/utils/settings/__tests__/permissionValidation-vault.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from 'bun:test' +import { validatePermissionRule } from '../permissionValidation.js' +import { filterInvalidPermissionRules } from '../validation.js' + +describe('validatePermissionRule (vault whole-tool allow rejection)', () => { + test('VaultHttpFetch whole-tool allow is rejected', () => { + const r = validatePermissionRule('VaultHttpFetch', 'allow') + expect(r.valid).toBe(false) + expect(r.error).toMatch(/whole-tool allow forbidden/i) + expect(r.suggestion).toMatch(/per-key/) + }) + + test('VaultHttpFetch whole-tool deny is allowed (kill switch)', () => { + const r = validatePermissionRule('VaultHttpFetch', 'deny') + expect(r.valid).toBe(true) + }) + + test('VaultHttpFetch whole-tool ask is allowed', () => { + const r = validatePermissionRule('VaultHttpFetch', 'ask') + expect(r.valid).toBe(true) + }) + + test('VaultHttpFetch with key@host content is allowed', () => { + const r = validatePermissionRule( + 'VaultHttpFetch(github-token@api.github.com)', + 'allow', + ) + expect(r.valid).toBe(true) + }) + + test('VaultHttpFetch with key@* (wildcard host) is allowed', () => { + const r = validatePermissionRule('VaultHttpFetch(my-key@*)', 'allow') + expect(r.valid).toBe(true) + }) + + test('VaultHttpFetch with bare key (no @host) is rejected', () => { + const r = validatePermissionRule('VaultHttpFetch(github-token)', 'allow') + expect(r.valid).toBe(false) + expect(r.error).toMatch(/@/) + }) + + test('VaultHttpFetch with malformed key@host is rejected', () => { + expect(validatePermissionRule('VaultHttpFetch(@host)', 'allow').valid).toBe( + false, + ) + expect(validatePermissionRule('VaultHttpFetch(key@)', 'allow').valid).toBe( + false, + ) + expect( + validatePermissionRule('VaultHttpFetch(key@@host)', 'allow').valid, + ).toBe(false) + }) + + test('F3 fix: bare-key deny is rejected (enforces same key@host format)', () => { + // Codex round 6 found that the validator accepted `VaultHttpFetch(key)` + // as a deny rule, but checkPermissions only matched key@host / key@* + // — so the rule passed parse but never fired. Now enforced uniformly: + // the user must use whole-tool kill switch OR explicit key@host form. + expect( + validatePermissionRule('VaultHttpFetch(github-token)', 'deny').valid, + ).toBe(false) + }) + + test('F3: per-key+host deny is accepted', () => { + expect( + validatePermissionRule( + 'VaultHttpFetch(github-token@api.github.com)', + 'deny', + ).valid, + ).toBe(true) + }) + + test('F2: host with port is accepted', () => { + expect( + validatePermissionRule( + 'VaultHttpFetch(local-admin@localhost:8443)', + 'allow', + ).valid, + ).toBe(true) + expect( + validatePermissionRule('VaultHttpFetch(api-key@127.0.0.1:8080)', 'allow') + .valid, + ).toBe(true) + }) + + test('F2: IPv6-bracketed host is accepted', () => { + expect( + validatePermissionRule('VaultHttpFetch(token@[::1]:8443)', 'allow').valid, + ).toBe(true) + }) + + test('LocalVaultFetch whole-tool allow is rejected (PR-3 future)', () => { + const r = validatePermissionRule('LocalVaultFetch', 'allow') + expect(r.valid).toBe(false) + }) + + test('non-vault tool whole-tool allow stays valid', () => { + expect(validatePermissionRule('Bash', 'allow').valid).toBe(true) + expect(validatePermissionRule('Read', 'allow').valid).toBe(true) + expect(validatePermissionRule('LocalMemoryRecall', 'allow').valid).toBe( + true, + ) + }) + + test('omitting behavior is backward-compatible: vault whole-tool passes syntax', () => { + // PermissionRuleSchema's superRefine path uses validatePermissionRule(rule) + // without behavior. The behavior-specific reject is layered ABOVE in + // filterInvalidPermissionRules, so the schema layer must remain permissive. + const r = validatePermissionRule('VaultHttpFetch') + expect(r.valid).toBe(true) + }) + + // ── H2 fix (codecov-100 audit): defensive ruleContent pre-validation ── + describe('H2: defensive ruleContent pre-validation (length cap + control chars)', () => { + test('regression: oversized (>384 char) ruleContent is rejected before regex runs', () => { + // Build a valid-looking but absurdly long content. Old code ran the + // regex on arbitrarily long inputs; new code rejects up front. + const longKey = 'a'.repeat(400) + const rule = `VaultHttpFetch(${longKey}@example.com)` + const result = validatePermissionRule(rule, 'allow') + expect(result.valid).toBe(false) + expect(result.error).toMatch(/too long/i) + }) + + test('regression: ruleContent at exactly 384 chars is accepted (boundary)', () => { + // 384 chars total (well below pathological); also short enough that + // the format regex runs. We craft a `@` whose total + // ruleContent length is <= 384 but uses up most of the budget. + const key = 'k'.repeat(120) // 120 + const host = 'h'.repeat(253) // 253 + const content = `${key}@${host}` // 120 + 1 + 253 = 374 chars + expect(content.length).toBeLessThanOrEqual(384) + const result = validatePermissionRule( + `VaultHttpFetch(${content})`, + 'allow', + ) + // Regex caps key at 128 chars and host at 253 — content is valid shape. + expect(result.valid).toBe(true) + }) + + test('regression: ruleContent with NUL byte is rejected', () => { + const result = validatePermissionRule( + 'VaultHttpFetch(key\x00bad@host)', + 'allow', + ) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/control character/i) + }) + + test('regression: ruleContent with TAB / newline / DEL is rejected', () => { + for (const ctrl of ['\t', '\n', '\r', '\x7F']) { + const result = validatePermissionRule( + `VaultHttpFetch(key${ctrl}bad@host)`, + 'allow', + ) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/control character/i) + } + }) + + test('valid printable rule content still passes', () => { + // Sanity check: H2 pre-validation must not break the existing happy path. + expect( + validatePermissionRule( + 'VaultHttpFetch(github-token@api.github.com)', + 'allow', + ).valid, + ).toBe(true) + expect( + validatePermissionRule('VaultHttpFetch(my-key@*)', 'deny').valid, + ).toBe(true) + }) + + test('H2 pre-validation also fires on deny path', () => { + const longKey = 'a'.repeat(400) + const result = validatePermissionRule( + `VaultHttpFetch(${longKey}@host)`, + 'deny', + ) + expect(result.valid).toBe(false) + expect(result.error).toMatch(/too long/i) + }) + }) +}) + +describe('filterInvalidPermissionRules (boot path integration)', () => { + test('strips VaultHttpFetch whole-tool from allow array, keeps deny', () => { + const data = { + permissions: { + allow: ['Bash', 'VaultHttpFetch', 'Read'], + deny: ['VaultHttpFetch', 'Bash(rm)'], + ask: [], + }, + } + const warnings = filterInvalidPermissionRules(data, '/test/settings.json') + expect(warnings.length).toBeGreaterThanOrEqual(1) + const allowWarning = warnings.find(w => w.path === 'permissions.allow') + expect(allowWarning).toBeDefined() + expect(allowWarning!.message).toMatch(/whole-tool allow forbidden/i) + + const allow = (data.permissions as { allow: string[] }).allow + const deny = (data.permissions as { deny: string[] }).deny + expect(allow).toEqual(['Bash', 'Read']) // VaultHttpFetch stripped + expect(deny).toEqual(['VaultHttpFetch', 'Bash(rm)']) // deny intact (kill switch) + }) + + test('per-key+host VaultHttpFetch in allow is preserved', () => { + const data = { + permissions: { + allow: [ + 'VaultHttpFetch(github-token@api.github.com)', + 'VaultHttpFetch(stripe-key@api.stripe.com)', + ], + deny: [], + ask: [], + }, + } + const warnings = filterInvalidPermissionRules(data, '/test/settings.json') + expect(warnings.length).toBe(0) + expect((data.permissions as { allow: string[] }).allow).toEqual([ + 'VaultHttpFetch(github-token@api.github.com)', + 'VaultHttpFetch(stripe-key@api.stripe.com)', + ]) + }) + + test('settings file with bad vault rule still produces other valid permissions (no crash)', () => { + // Critical: a single bad rule must NOT cause settings to return null. + // The boot path is filterInvalidPermissionRules → SettingsSchema().safeParse. + // After filter, VaultHttpFetch whole-tool is gone, so safeParse will + // still succeed. + const data = { + permissions: { + allow: ['VaultHttpFetch'], // bad + deny: ['VaultHttpFetch'], // good (kill switch) + }, + otherSetting: 'preserved', + } + filterInvalidPermissionRules(data, '/test/settings.json') + // Other settings preserved; allow array became empty + expect((data as { otherSetting: string }).otherSetting).toBe('preserved') + expect((data.permissions as { allow: string[] }).allow).toEqual([]) + expect((data.permissions as { deny: string[] }).deny).toEqual([ + 'VaultHttpFetch', + ]) + }) +}) diff --git a/src/utils/settings/permissionValidation.ts b/src/utils/settings/permissionValidation.ts index 7d04c8a7b..76d6c1a36 100644 --- a/src/utils/settings/permissionValidation.ts +++ b/src/utils/settings/permissionValidation.ts @@ -53,9 +53,38 @@ function hasUnescapedEmptyParens(str: string): boolean { } /** - * Validates permission rule format and content + * Tool names where a "whole-tool" allow rule (no parentheses, no ruleContent) + * is forbidden. These tools serve user secrets to the model and require + * per-key explicit allow. Whole-tool deny is fine (acts as kill switch). + * + * L4 note: 'LocalVaultFetch' is registered preemptively for a not-yet-built + * future tool. If that tool ships under a different name, this entry becomes + * dead and should be cleaned up. */ -export function validatePermissionRule(rule: string): { +const VAULT_WHOLE_TOOL_ALLOW_FORBIDDEN = new Set([ + 'LocalVaultFetch', // future tool (not yet implemented; safe to remove if renamed) + 'VaultHttpFetch', // PR-2 (LOCAL-WIRING) +]) + +/** + * Validates permission rule format and content. + * + * @param rule The rule string (e.g. "Bash(npm install)" or "VaultHttpFetch(github-token)") + * @param behavior Optional context: 'allow' | 'deny' | 'ask'. When provided, + * enables behavior-specific checks (e.g. reject `permissions.allow:[VaultHttpFetch]` + * whole-tool allow on vault tools while still permitting the same form under + * `permissions.deny` as a kill switch). + * + * Backward compatible: existing callers that don't pass behavior get the + * syntactic-only validation they had before. The PermissionRuleSchema zod + * superRefine path (line ~244) deliberately omits behavior since the array + * it validates is shape-uniform; the behavior-aware filtering happens + * earlier in filterInvalidPermissionRules where the array key is known. + */ +export function validatePermissionRule( + rule: string, + behavior?: 'allow' | 'deny' | 'ask', +): { valid: boolean error?: string suggestion?: string @@ -235,6 +264,126 @@ export function validatePermissionRule(rule: string): { } } + // H2 fix (codecov-100 audit): defensive pre-validation of ruleContent + // before any regex is run. The hardcoded regexes below are linear-time + // for valid input (no backtracking on the `*`-bounded character classes + // we use), but a maliciously long ruleContent string still costs O(n) + // to scan and could be a vector if a future commit adds `new RegExp()` + // with user-supplied content. Reject obviously pathological input up + // front: oversized, control characters, or non-printable bytes. + if ( + parsed && + parsed.toolName === 'VaultHttpFetch' && + parsed.ruleContent !== undefined + ) { + const rc = parsed.ruleContent + // Hard cap: 256 chars is well over our regex's max practical length + // (128 + 1 + 253 + 6 = 388 worst-case for IPv6+port; 256 keeps the + // worst-case work bounded for the common `@` shape). + if (rc.length > 384) { + return { + valid: false, + error: `VaultHttpFetch rule content is too long (${rc.length} chars; max 384)`, + suggestion: + 'Use a shorter key name and host, or use the wildcard form @*', + } + } + // Reject control / non-printable bytes — these can't appear in a + // valid @ rule and may indicate copy-paste corruption + // or an attempt to smuggle smt into a future regex. + // biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately rejecting control chars + if (/[\x00-\x1F\x7F]/.test(rc)) { + return { + valid: false, + error: + 'VaultHttpFetch rule content contains control characters (only printable ASCII allowed in key@host)', + suggestion: 'Remove control characters from the rule content', + } + } + } + + // F3 fix (Codex round 6): apply the same `@` enforcement on + // the deny path. A bare `VaultHttpFetch(github-token)` deny rule was + // previously accepted by the validator but ignored at runtime + // (checkPermissions only looks up `key@host` and `key@*`). Either we + // enforce the format on deny too (so user gets an immediate error and + // writes the right shape), or we update checkPermissions to fall back + // on bare-key match. Enforcing the format is simpler and gives a clear + // error path. + if ( + parsed && + parsed.toolName === 'VaultHttpFetch' && + behavior === 'deny' && + parsed.ruleContent !== undefined && + !/^[A-Za-z0-9._-]{1,128}@(?:\*|(?:\[[A-Fa-f0-9:]+\]|[A-Za-z0-9.-]{1,253})(?::\d{1,5})?)$/.test( + parsed.ruleContent, + ) + ) { + return { + valid: false, + error: `VaultHttpFetch deny rule content must be '@' or '@*' (or whole-tool deny without parentheses for kill switch)`, + suggestion: `Found '${parsed.ruleContent}'. Use 'VaultHttpFetch' (no parens) for kill switch, or 'VaultHttpFetch(${parsed.ruleContent}@*)' for any-host.`, + examples: [ + 'VaultHttpFetch — whole-tool kill switch', + `VaultHttpFetch(${parsed.ruleContent}@api.github.com)`, + `VaultHttpFetch(${parsed.ruleContent}@*)`, + ], + } + } + + // Behavior-aware checks for vault-class tools. + // Re-uses the `parsed` result from line 125 (no second parse call). + if (behavior === 'allow' && parsed) { + // Forbid whole-tool allow (no parentheses, no ruleContent). + if ( + parsed.ruleContent === undefined && + VAULT_WHOLE_TOOL_ALLOW_FORBIDDEN.has(parsed.toolName) + ) { + return { + valid: false, + error: `Whole-tool allow forbidden for vault tool '${parsed.toolName}'`, + suggestion: `Use per-key + per-host allow: '${parsed.toolName}(your-key-name@host)'`, + examples: [ + `${parsed.toolName}(github-token@api.github.com)`, + `${parsed.toolName}(my-api@*) - allow any host (advanced)`, + ], + } + } + // For VaultHttpFetch specifically, require the rule content to be + // formatted as `@` (or `@*` for the explicit wildcard). + // A bare `VaultHttpFetch(key)` rule is rejected to prevent users + // mistakenly granting "any host" by accident — they must opt into + // wildcard via the explicit `@*` syntax. + // + // F2 fix (Codex round 6): host portion must accept a port (e.g. + // `api.example.com:8443`) since URL.host includes the port. Also + // accept IPv4 / IPv6-bracketed forms. + // + // Host grammar (subset of RFC 3986 authority): + // host = name / ipv4 / "[" ipv6 "]" + // port = ":" 1*DIGIT (optional) + // name char = [A-Za-z0-9.-] + // ipv6 char = [A-Fa-f0-9:] + if ( + parsed.toolName === 'VaultHttpFetch' && + parsed.ruleContent !== undefined && + !/^[A-Za-z0-9._-]{1,128}@(?:\*|(?:\[[A-Fa-f0-9:]+\]|[A-Za-z0-9.-]{1,253})(?::\d{1,5})?)$/.test( + parsed.ruleContent, + ) + ) { + return { + valid: false, + error: `VaultHttpFetch rule content must be '@' or '@*'`, + suggestion: `Found '${parsed.ruleContent}'. Use e.g. 'github-token@api.github.com' or 'admin-key@127.0.0.1:8443' to bind a key to a host.`, + examples: [ + 'VaultHttpFetch(github-token@api.github.com)', + 'VaultHttpFetch(local-admin@localhost:8443)', + 'VaultHttpFetch(stripe-key@*) - any host (advanced)', + ], + } + } + } + return { valid: true } } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 430ed25b7..678eb5c76 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -556,6 +556,14 @@ export const SettingsSchema = lazySchema(() => }) .optional() .describe('Custom status line display configuration'), + // Toggle for the fork's built-in status line (BuiltinStatusLine + CachePill). + // Toggled by the /statusline command. Default false → no rendering. + statusLineEnabled: z + .boolean() + .optional() + .describe( + 'Whether to render the fork built-in status line (model + ctx + 5h/7d limits + cost + cache pill). Toggled with /statusline.', + ), // Enabled plugins using marketplace-first format enabledPlugins: z .record( @@ -1090,6 +1098,24 @@ export const SettingsSchema = lazySchema(() => 'Useful for enterprise administrators to add organization-specific context ' + '(e.g., "All plugins from our internal marketplace are vetted and approved.").', ), + /** + * Workspace API key stored in settings.json for /login UI convenience. + * + * ⚠️ SECURITY NOTICE: stored in plaintext in ~/.claude.json — ensure this + * file is gitignored and has restricted permissions (chmod 600 on POSIX). + * Use ANTHROPIC_API_KEY env var in CI/CD or shared environments instead. + * + * Must start with "sk-ant-api03-". Read via getGlobalConfig().workspaceApiKey + * or the ANTHROPIC_API_KEY env var (env var takes precedence). + */ + workspaceApiKey: z + .string() + .optional() + .describe( + 'Workspace API key (sk-ant-api03-*) saved via /login UI. ' + + 'Stored in plaintext — keep this file gitignored and restrict its permissions. ' + + 'ANTHROPIC_API_KEY environment variable takes precedence when both are set.', + ), }) .passthrough(), ) diff --git a/src/utils/settings/validation.ts b/src/utils/settings/validation.ts index fc4744c14..53942050a 100644 --- a/src/utils/settings/validation.ts +++ b/src/utils/settings/validation.ts @@ -231,7 +231,7 @@ export function filterInvalidPermissionRules( const perms = obj.permissions as Record const warnings: ValidationError[] = [] - for (const key of ['allow', 'deny', 'ask']) { + for (const key of ['allow', 'deny', 'ask'] as const) { const rules = perms[key] if (!Array.isArray(rules)) continue @@ -245,7 +245,9 @@ export function filterInvalidPermissionRules( }) return false } - const result = validatePermissionRule(rule) + // PR-0a: pass behavior so vault whole-tool allow is rejected on the + // allow array but the same rule under deny stays as a kill switch. + const result = validatePermissionRule(rule, key) if (!result.valid) { let message = `Invalid permission rule "${rule}" was skipped` if (result.error) message += `: ${result.error}`