mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +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:
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