mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +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:
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}`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user