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,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',
])
})
})

View File

@@ -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 }
}

View File

@@ -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(),
)

View File

@@ -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}`