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:
claude-code-best
2026-05-09 23:04:20 +08:00
parent 2437040b5b
commit 4f0aa8615a
16 changed files with 2577 additions and 4 deletions

View 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 &lt;key&gt; &lt;value&gt; 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>
);
}

View 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)
}
})
})

View 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')
})
})
})

View 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;

View 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);
};

View 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}`,
}
}