mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加本地 Memory/Vault 管理命令
- /local-memory: 本地记忆管理(store/entry CRUD、搜索、归档) - /local-vault: 本地密钥保险库管理(加解密、keychain 集成) - permissionValidation: vault 权限校验增强 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
136
src/commands/local-memory/LocalMemoryView.tsx
Normal file
136
src/commands/local-memory/LocalMemoryView.tsx
Normal file
@@ -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 (
|
||||
<Box>
|
||||
<Text dimColor>No memory stores found. Use /local-memory create <store> to create one.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Local Memory Stores ({props.stores.length})</Text>
|
||||
</Box>
|
||||
{props.stores.map(s => (
|
||||
<Box key={s}>
|
||||
<Text> </Text>
|
||||
<Text color={'success' as keyof Theme}>◆</Text>
|
||||
<Text> {s}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'created') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>✓</Text>
|
||||
<Text> Store created: </Text>
|
||||
<Text bold>{props.store}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'stored') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>✓</Text>
|
||||
<Text> Stored entry </Text>
|
||||
<Text bold>{props.key}</Text>
|
||||
<Text> in </Text>
|
||||
<Text bold>{props.store}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'fetched') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{props.store}</Text>
|
||||
<Text dimColor>/</Text>
|
||||
<Text bold>{props.key}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{props.value}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'not-found') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>Not found: </Text>
|
||||
<Text bold>{props.store}</Text>
|
||||
{props.key ? (
|
||||
<>
|
||||
<Text dimColor>/</Text>
|
||||
<Text bold>{props.key}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'entries') {
|
||||
if (props.keys.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>No entries in </Text>
|
||||
<Text bold>{props.store}</Text>
|
||||
<Text dimColor>. Use /local-memory store {props.store} <key> <value> to add one.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{props.store}</Text>
|
||||
<Text dimColor> ({props.keys.length} entries)</Text>
|
||||
</Box>
|
||||
{props.keys.map(k => (
|
||||
<Box key={k}>
|
||||
<Text> </Text>
|
||||
<Text color={'success' as keyof Theme}>·</Text>
|
||||
<Text> {k}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'archived') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>✓</Text>
|
||||
<Text> Archived store: </Text>
|
||||
<Text bold>{props.store}</Text>
|
||||
<Text dimColor> (renamed to {props.store}.archived)</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// mode === 'error'
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>Error: {props.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
227
src/commands/local-memory/__tests__/launchLocalMemory.test.ts
Normal file
227
src/commands/local-memory/__tests__/launchLocalMemory.test.ts
Normal file
@@ -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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'',
|
||||
)
|
||||
|
||||
expect(node).not.toBeNull()
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('list sub-command with no stores', async () => {
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create test-store',
|
||||
)
|
||||
expect(messages.some(m => m.includes('test-store'))).toBe(true)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create notes',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create fetch-store',
|
||||
)
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'store fetch-store mykey my entry value',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create empty-s',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create ent-store',
|
||||
)
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'store ent-store alpha value-a',
|
||||
)
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'store ent-store beta value-b',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create to-archive',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[1],
|
||||
'create dup-store',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalMemory(
|
||||
onDone as Parameters<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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<typeof callLocalMemory>[0],
|
||||
{} as Parameters<typeof callLocalMemory>[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')
|
||||
})
|
||||
})
|
||||
106
src/commands/local-memory/__tests__/parseArgs.test.ts
Normal file
106
src/commands/local-memory/__tests__/parseArgs.test.ts
Normal file
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
22
src/commands/local-memory/index.tsx
Normal file
22
src/commands/local-memory/index.tsx
Normal file
@@ -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 `<store>` / `<key>` / `<value>` 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;
|
||||
527
src/commands/local-memory/launchLocalMemory.tsx
Normal file
527
src/commands/local-memory/launchLocalMemory.tsx
Normal file
@@ -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<typeof LocalMemoryView>;
|
||||
|
||||
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<Step>({ kind: 'menu' });
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [textValue, setTextValue] = React.useState('');
|
||||
const [cursorOffset, setCursorOffset] = React.useState(0);
|
||||
const [error, setError] = React.useState<string | null>(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 (
|
||||
<Dialog
|
||||
title="Local Memory"
|
||||
subtitle={`${MENU.length} actions`}
|
||||
onCancel={() => closeWith('Local memory panel dismissed')}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{MENU.map((m, i) => (
|
||||
<Box key={m.kind} flexDirection="row">
|
||||
<Text>{`${i === selectedIndex ? '›' : ' '} ${m.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
|
||||
<Text dimColor>{m.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑/↓ or 1-7 select · Enter run · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirmation prompts
|
||||
if (step.kind === 'confirm-archive') {
|
||||
return (
|
||||
<Dialog title="Confirm Archive" onCancel={() => transition({ kind: 'menu' })} color="warning" hideInputGuide>
|
||||
<Box flexDirection="column">
|
||||
<Text>Archive store "{step.store}"? This renames it to *.archived.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>y/Enter = archive · n/Esc = cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
if (step.kind === 'confirm-overwrite') {
|
||||
return (
|
||||
<Dialog title="Confirm Overwrite" onCancel={() => transition({ kind: 'menu' })} color="warning" hideInputGuide>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
Entry "{step.store}/{step.key}" already exists. Overwrite with new value ({step.value.length} chars)?
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>y/Enter = overwrite · n/Esc = cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Dialog
|
||||
title={`Local Memory · ${step.kind.replace('collect-', '').toUpperCase()}`}
|
||||
onCancel={() => transition({ kind: 'menu' })}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>{fieldLabel}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{'> '}</Text>
|
||||
<TextInput
|
||||
value={textValue}
|
||||
onChange={v => {
|
||||
setTextValue(v);
|
||||
setError(null);
|
||||
}}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
onSubmit={validateAndAdvance}
|
||||
placeholder={placeholder}
|
||||
columns={70}
|
||||
showCursor
|
||||
/>
|
||||
</Box>
|
||||
{error !== null && (
|
||||
<Box marginTop={0}>
|
||||
<Text color="warning">✗ {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Enter = next · Esc = back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchLocalMemory(
|
||||
parsed: ReturnType<typeof parseLocalMemoryArgs>,
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<LocalMemoryViewProps | null> {
|
||||
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<typeof parseLocalMemoryArgs>,
|
||||
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 <LocalMemoryPanel onDone={onDone} />;
|
||||
}
|
||||
return callLocalMemoryDirect(onDone, context, args);
|
||||
};
|
||||
122
src/commands/local-memory/parseArgs.ts
Normal file
122
src/commands/local-memory/parseArgs.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Parse the args string for the /local-memory command.
|
||||
*
|
||||
* Supported sub-commands:
|
||||
* list → { action: 'list' }
|
||||
* create <store> → { action: 'create', store }
|
||||
* store <store> <key> <value> → { action: 'store', store, key, value }
|
||||
* fetch <store> <key> → { action: 'fetch', store, key }
|
||||
* entries <store> → { action: 'entries', store }
|
||||
* archive <store> → { 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 `<store>` / `<key>` / `<value>` 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}`,
|
||||
}
|
||||
}
|
||||
107
src/commands/local-vault/LocalVaultView.tsx
Normal file
107
src/commands/local-vault/LocalVaultView.tsx
Normal file
@@ -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 (
|
||||
<Box>
|
||||
<Text dimColor>No secrets stored. Use /local-vault set <key> <value> to add one.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Local Vault Keys ({props.keys.length})</Text>
|
||||
</Box>
|
||||
{props.keys.map(k => (
|
||||
<Box key={k}>
|
||||
<Text> </Text>
|
||||
<Text color={'success' as keyof Theme}>●</Text>
|
||||
<Text> {k}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'set-ok') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>✓</Text>
|
||||
<Text> Secret stored: </Text>
|
||||
<Text bold>{props.key}</Text>
|
||||
<Text dimColor> = [REDACTED]</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'get-masked') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold>{props.key}</Text>
|
||||
<Text dimColor>: </Text>
|
||||
<Text>{props.masked}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Use /local-vault get {props.key} --reveal to see the full value.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'get-revealed') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold>{props.key}</Text>
|
||||
<Text dimColor>: </Text>
|
||||
<Text color={'warning' as keyof Theme}>{props.value}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor color={'warning' as keyof Theme}>
|
||||
⚠ Secret revealed in terminal — clear scrollback if this session is shared.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'not-found') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>Key not found: </Text>
|
||||
<Text bold>{props.key}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'deleted') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>✓</Text>
|
||||
<Text> Deleted: </Text>
|
||||
<Text bold>{props.key}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// mode === 'error'
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>Error: {props.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
192
src/commands/local-vault/__tests__/launchLocalVault.test.ts
Normal file
192
src/commands/local-vault/__tests__/launchLocalVault.test.ts
Normal file
@@ -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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
'',
|
||||
)
|
||||
|
||||
expect(node).not.toBeNull()
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('list sub-command shows key count', async () => {
|
||||
await callLocalVault(
|
||||
onDone as Parameters<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
`set KEY_MASK ${secretValue}`,
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalVault(
|
||||
onDone as Parameters<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
`set REVEAL_KEY ${secretValue}`,
|
||||
)
|
||||
messages.length = 0
|
||||
const node = await callLocalVault(
|
||||
onDone as Parameters<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
`set MASK_CHECK ${secretValue}`,
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalVault(
|
||||
onDone as Parameters<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
'set TO_DEL_KEY some-value',
|
||||
)
|
||||
messages.length = 0
|
||||
await callLocalVault(
|
||||
onDone as Parameters<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[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<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
`set INV_KEY ${secret}`,
|
||||
)
|
||||
messages.length = 0
|
||||
// Without --reveal
|
||||
await callLocalVault(
|
||||
onDone as Parameters<typeof callLocalVault>[0],
|
||||
{} as Parameters<typeof callLocalVault>[1],
|
||||
'get INV_KEY',
|
||||
)
|
||||
for (const msg of messages) {
|
||||
expect(msg).not.toContain(secret)
|
||||
}
|
||||
})
|
||||
})
|
||||
146
src/commands/local-vault/__tests__/parseArgs.test.ts
Normal file
146
src/commands/local-vault/__tests__/parseArgs.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
21
src/commands/local-vault/index.tsx
Normal file
21
src/commands/local-vault/index.tsx
Normal file
@@ -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 `<key>` / `<value>` 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;
|
||||
428
src/commands/local-vault/launchLocalVault.tsx
Normal file
428
src/commands/local-vault/launchLocalVault.tsx
Normal file
@@ -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<typeof LocalVaultView>;
|
||||
|
||||
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<VaultStep>({ kind: 'menu' });
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [textValue, setTextValue] = React.useState('');
|
||||
const [cursorOffset, setCursorOffset] = React.useState(0);
|
||||
const [error, setError] = React.useState<string | null>(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 (
|
||||
<Dialog
|
||||
title="Local Vault"
|
||||
subtitle={`${VAULT_MENU.length} actions`}
|
||||
onCancel={() => closeWith('Local vault panel dismissed')}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{VAULT_MENU.map((m, i) => (
|
||||
<Box key={m.kind} flexDirection="row">
|
||||
<Text>{`${i === selectedIndex ? '›' : ' '} ${m.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
|
||||
<Text dimColor>{m.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{inFlight && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Working...</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑/↓ or 1-5 select · Enter run · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (step.kind === 'confirm-delete') {
|
||||
return (
|
||||
<Dialog title="Confirm Delete" onCancel={() => transition({ kind: 'menu' })} color="warning" hideInputGuide>
|
||||
<Box flexDirection="column">
|
||||
<Text>Delete secret "{step.key}"? This cannot be undone.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>y/Enter = delete · n/Esc = cancel</Text>
|
||||
</Box>
|
||||
{inFlight && <Text dimColor>Deleting...</Text>}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (step.kind === 'confirm-overwrite') {
|
||||
return (
|
||||
<Dialog title="Confirm Overwrite" onCancel={() => transition({ kind: 'menu' })} color="warning" hideInputGuide>
|
||||
<Box flexDirection="column">
|
||||
<Text>Secret "{step.key}" already exists. Overwrite? Old value is lost.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>y/Enter = overwrite · n/Esc = cancel</Text>
|
||||
</Box>
|
||||
{inFlight && <Text dimColor>Storing...</Text>}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Dialog
|
||||
title={`Local Vault · ${step.kind === 'collect-key' ? 'KEY' : 'VALUE'}`}
|
||||
onCancel={() => transition({ kind: 'menu' })}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>{fieldLabel}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{'> '}</Text>
|
||||
<TextInput
|
||||
value={textValue}
|
||||
onChange={v => {
|
||||
setTextValue(v);
|
||||
setError(null);
|
||||
}}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
onSubmit={onSubmit}
|
||||
placeholder={placeholder}
|
||||
columns={70}
|
||||
showCursor
|
||||
mask={isMasked ? '*' : undefined}
|
||||
/>
|
||||
</Box>
|
||||
{error !== null && (
|
||||
<Box marginTop={0}>
|
||||
<Text color="warning">✗ {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{inFlight && (
|
||||
<Box marginTop={0}>
|
||||
<Text dimColor>Working...</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Enter = next · Esc = back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchLocalVault(
|
||||
parsed: ReturnType<typeof parseLocalVaultArgs>,
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<LocalVaultViewProps | null> {
|
||||
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<typeof parseLocalVaultArgs>,
|
||||
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 <LocalVaultPanel onDone={onDone} />;
|
||||
}
|
||||
return callLocalVaultDirect(onDone, context, args);
|
||||
};
|
||||
116
src/commands/local-vault/parseArgs.ts
Normal file
116
src/commands/local-vault/parseArgs.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Parse the args string for the /local-vault command.
|
||||
*
|
||||
* Supported sub-commands:
|
||||
* list → { action: 'list' }
|
||||
* set <key> <value> → { action: 'set', key, value }
|
||||
* get <key> → { action: 'get', key, reveal: false }
|
||||
* get <key> --reveal → { action: 'get', key, reveal: true }
|
||||
* delete <key> → { 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 `<key>` / `<value>` 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}`,
|
||||
}
|
||||
}
|
||||
246
src/utils/settings/__tests__/permissionValidation-vault.test.ts
Normal file
246
src/utils/settings/__tests__/permissionValidation-vault.test.ts
Normal file
@@ -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(/<key>@<host>/)
|
||||
})
|
||||
|
||||
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 `<key>@<host>` 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<string>([
|
||||
'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 `<key>@<host>` 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 <key>@*',
|
||||
}
|
||||
}
|
||||
// Reject control / non-printable bytes — these can't appear in a
|
||||
// valid <key>@<host> 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 `<key>@<host>` 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 '<key>@<host>' or '<key>@*' (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 `<key>@<host>` (or `<key>@*` 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 '<key>@<host>' or '<key>@*'`,
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -231,7 +231,7 @@ export function filterInvalidPermissionRules(
|
||||
const perms = obj.permissions as Record<string, unknown>
|
||||
|
||||
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}`
|
||||
|
||||
Reference in New Issue
Block a user