mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
feat: 添加登录认证增强(workspace key、host guard、auth status)
- hostGuard: workspace API key 仅限 api.anthropic.com,OAuth 限定 subscription plane - saveWorkspaceKey: sk-ant-api03- 前缀校验,安全写入缓存 - AuthPlaneSummary/WorkspaceKeyInput: 登录 UI 组件 - getAuthStatus: 认证状态查询 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
186
src/services/auth/__tests__/hostGuard.test.ts
Normal file
186
src/services/auth/__tests__/hostGuard.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Regression tests for src/services/auth/hostGuard.ts
|
||||
*
|
||||
* Tests verify:
|
||||
* - assertWorkspaceHost: passes for api.anthropic.com, throws for third-party hosts
|
||||
* - assertSubscriptionBaseUrl: passes for api.anthropic.com, throws for third-party hosts
|
||||
* - assertNoAnthropicEnvForOpenAI: logs warning (does not throw) when both env vars set
|
||||
*
|
||||
* NOTE: This file imports hostGuard functions LAZILY (in beforeAll) so that the
|
||||
* module is resolved after any mock.module calls. Do NOT mock hostGuard.js in
|
||||
* other test files — it would replace the real module in the process-level cache.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
// Side-effect module mocks must come first
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
let assertWorkspaceHost: typeof import('../hostGuard.js').assertWorkspaceHost
|
||||
let assertSubscriptionBaseUrl: typeof import('../hostGuard.js').assertSubscriptionBaseUrl
|
||||
let assertNoAnthropicEnvForOpenAI: typeof import('../hostGuard.js').assertNoAnthropicEnvForOpenAI
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../hostGuard.js')
|
||||
assertWorkspaceHost = mod.assertWorkspaceHost
|
||||
assertSubscriptionBaseUrl = mod.assertSubscriptionBaseUrl
|
||||
assertNoAnthropicEnvForOpenAI = mod.assertNoAnthropicEnvForOpenAI
|
||||
})
|
||||
|
||||
// ── assertWorkspaceHost ─────────────────────────────────────────────────────
|
||||
|
||||
describe('assertWorkspaceHost', () => {
|
||||
test('passes for https://api.anthropic.com/v1/agents', () => {
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.anthropic.com/v1/agents'),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('passes for https://api.anthropic.com/v1/vaults', () => {
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.anthropic.com/v1/vaults'),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('passes for https://api.anthropic.com/v1/memory_stores', () => {
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.anthropic.com/v1/memory_stores'),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('throws for third-party host (api.cerebras.ai)', () => {
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.cerebras.ai/v1/agents'),
|
||||
).toThrow('non-Anthropic host')
|
||||
})
|
||||
|
||||
test('throws for third-party host (api.openai.com)', () => {
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.openai.com/v1/agents'),
|
||||
).toThrow('non-Anthropic host')
|
||||
})
|
||||
|
||||
test('throws for attacker host', () => {
|
||||
expect(() => assertWorkspaceHost('https://attacker.com/steal')).toThrow(
|
||||
'non-Anthropic host',
|
||||
)
|
||||
})
|
||||
|
||||
test('throws for invalid URL', () => {
|
||||
expect(() => assertWorkspaceHost('not-a-url')).toThrow('invalid URL')
|
||||
})
|
||||
|
||||
test('error message contains workspace API key hint', () => {
|
||||
let message = ''
|
||||
try {
|
||||
assertWorkspaceHost('https://api.cerebras.ai/v1/agents')
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
expect(message).toContain('api.anthropic.com')
|
||||
})
|
||||
|
||||
// E2 regression: hostname-based check catches subdomain-confusion attacks
|
||||
test('throws for api.anthropic.com.evil.com (subdomain confusion)', () => {
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.anthropic.com.evil.com/v1/agents'),
|
||||
).toThrow('non-Anthropic host')
|
||||
})
|
||||
|
||||
test('throws for URL with credentials (url@host bypass attempt)', () => {
|
||||
// new URL('https://api.anthropic.com@evil.com/').hostname === 'evil.com'
|
||||
// so this is caught by hostname !== WORKSPACE_API_HOST
|
||||
expect(() =>
|
||||
assertWorkspaceHost('https://api.anthropic.com@evil.com/v1/agents'),
|
||||
).toThrow('non-Anthropic host')
|
||||
})
|
||||
})
|
||||
|
||||
// ── assertSubscriptionBaseUrl ───────────────────────────────────────────────
|
||||
|
||||
describe('assertSubscriptionBaseUrl', () => {
|
||||
test('passes for https://api.anthropic.com/v1/code/triggers', () => {
|
||||
expect(() =>
|
||||
assertSubscriptionBaseUrl('https://api.anthropic.com/v1/code/triggers'),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('passes for https://api.anthropic.com/v1/sessions', () => {
|
||||
expect(() =>
|
||||
assertSubscriptionBaseUrl('https://api.anthropic.com/v1/sessions'),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('throws for attacker.com', () => {
|
||||
expect(() =>
|
||||
assertSubscriptionBaseUrl('https://attacker.com/steal'),
|
||||
).toThrow('non-Anthropic host')
|
||||
})
|
||||
|
||||
test('throws for third-party host', () => {
|
||||
expect(() =>
|
||||
assertSubscriptionBaseUrl('https://api.openai.com/v1/chat/completions'),
|
||||
).toThrow('non-Anthropic host')
|
||||
})
|
||||
|
||||
test('throws for invalid URL', () => {
|
||||
expect(() => assertSubscriptionBaseUrl('not-a-url')).toThrow('invalid URL')
|
||||
})
|
||||
})
|
||||
|
||||
// ── assertNoAnthropicEnvForOpenAI ───────────────────────────────────────────
|
||||
|
||||
describe('assertNoAnthropicEnvForOpenAI', () => {
|
||||
const origAnthropicKey = process.env['ANTHROPIC_API_KEY']
|
||||
const origOpenAIKey = process.env['OPENAI_API_KEY']
|
||||
const origOpenAIMode = process.env['CLAUDE_CODE_USE_OPENAI']
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars
|
||||
if (origAnthropicKey === undefined) {
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
} else {
|
||||
process.env['ANTHROPIC_API_KEY'] = origAnthropicKey
|
||||
}
|
||||
if (origOpenAIKey === undefined) {
|
||||
delete process.env['OPENAI_API_KEY']
|
||||
} else {
|
||||
process.env['OPENAI_API_KEY'] = origOpenAIKey
|
||||
}
|
||||
if (origOpenAIMode === undefined) {
|
||||
delete process.env['CLAUDE_CODE_USE_OPENAI']
|
||||
} else {
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] = origOpenAIMode
|
||||
}
|
||||
})
|
||||
|
||||
test('does not throw when only ANTHROPIC_API_KEY is set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test'
|
||||
delete process.env['OPENAI_API_KEY']
|
||||
delete process.env['CLAUDE_CODE_USE_OPENAI']
|
||||
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw when only OpenAI mode is set', () => {
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] = '1'
|
||||
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw (only warns) when both ANTHROPIC_API_KEY and OPENAI_API_KEY are set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test'
|
||||
process.env['OPENAI_API_KEY'] = 'sk-openai-test'
|
||||
// Must NOT throw
|
||||
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw (only warns) when both ANTHROPIC_API_KEY and CLAUDE_CODE_USE_OPENAI=1 are set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-api03-test'
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] = '1'
|
||||
// Must NOT throw
|
||||
expect(() => assertNoAnthropicEnvForOpenAI()).not.toThrow()
|
||||
})
|
||||
})
|
||||
141
src/services/auth/__tests__/saveWorkspaceKey.test.ts
Normal file
141
src/services/auth/__tests__/saveWorkspaceKey.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Regression tests for saveWorkspaceKey.ts
|
||||
* Tests: valid key / wrong prefix / empty / too short / too long / error mask
|
||||
*
|
||||
* Uses Bun's test-mode saveGlobalConfig (NODE_ENV=test writes to
|
||||
* TEST_GLOBAL_CONFIG_FOR_TESTING in-memory, no disk I/O needed).
|
||||
* The tryChmod600 step may log an error (non-existent test file) — that is fine.
|
||||
*/
|
||||
import { afterAll, describe, expect, test, mock } from 'bun:test'
|
||||
import { logMock } from '../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../tests/mocks/debug'
|
||||
|
||||
// Mock side-effect modules first
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
// Pre-import the real settings module so we keep all its exports for any
|
||||
// downstream test file in the same process (mock.module is global).
|
||||
// We override the two keys this suite uses; the rest delegates to real impls.
|
||||
const _realSettings = await import('src/utils/settings/settings.js')
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
..._realSettings,
|
||||
getCachedOrDefaultSettings: () => ({}),
|
||||
getSettings: () => ({}),
|
||||
}))
|
||||
|
||||
// Mock src/utils/config.ts with closure-driven impls and a flag-gated noop
|
||||
// fallback. Other test files (e.g. processSlashCommand.test.ts) run in the
|
||||
// same process and call saveGlobalConfig via recordSkillUsage; if our last
|
||||
// mock leaves a "throw new Error('disk full')" body installed, those calls
|
||||
// crash. After this suite we flip useMockForConfig=false so the noop fallback
|
||||
// returns undefined for getGlobalConfig/saveGlobalConfig — matching the
|
||||
// behavior of unmocked side-effect-free defaults rather than throwing.
|
||||
let _useMockForConfig = true
|
||||
let _mockGetGlobalConfig: () => unknown = () => ({
|
||||
workspaceApiKey: undefined,
|
||||
})
|
||||
let _mockSaveGlobalConfig: (updater: unknown) => unknown = (_u: unknown) =>
|
||||
undefined
|
||||
mock.module('src/utils/config.ts', () => ({
|
||||
isConfigEnabled: () => true,
|
||||
getGlobalConfig: () =>
|
||||
_useMockForConfig ? _mockGetGlobalConfig() : { workspaceApiKey: undefined },
|
||||
saveGlobalConfig: (updater: unknown) =>
|
||||
_useMockForConfig ? _mockSaveGlobalConfig(updater) : undefined,
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
_useMockForConfig = false
|
||||
// Reset closure state so nothing leaks even if a teammate test elsewhere
|
||||
// re-flips the flag.
|
||||
_mockGetGlobalConfig = () => ({ workspaceApiKey: undefined })
|
||||
_mockSaveGlobalConfig = () => undefined
|
||||
})
|
||||
// Provide a stable path so tryChmod600 at least knows which file to chmod
|
||||
// (it will fail gracefully for a non-existent file and log via logError)
|
||||
mock.module('src/utils/env.ts', () => ({
|
||||
getGlobalClaudeFile: () => '/tmp/.claude-saveWorkspaceKey-test.json',
|
||||
getClaudeConfigHomeDir: () => '/tmp/.claude-test',
|
||||
}))
|
||||
|
||||
describe('saveWorkspaceKey', () => {
|
||||
test('saves valid sk-ant-api03-* key successfully', async () => {
|
||||
const { saveWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
const key = 'sk-ant-api03-' + 'A'.repeat(80)
|
||||
// Should not throw (chmod error is non-fatal)
|
||||
await expect(saveWorkspaceKey(key)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('rejects key without sk-ant-api03- prefix', async () => {
|
||||
const { saveWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
await expect(
|
||||
saveWorkspaceKey('sk-wrong-prefix-' + 'A'.repeat(80)),
|
||||
).rejects.toThrow(/sk-ant-api03-/)
|
||||
})
|
||||
|
||||
test('rejects empty key', async () => {
|
||||
const { saveWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
await expect(saveWorkspaceKey('')).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('rejects key shorter than minimum length', async () => {
|
||||
const { saveWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
// 'sk-ant-api03-short' = 18 chars (< MIN_KEY_LENGTH 20)
|
||||
await expect(saveWorkspaceKey('sk-ant-api03-short')).rejects.toThrow(
|
||||
/short|minimum/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects key longer than 256 chars', async () => {
|
||||
const { saveWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
const tooLong = 'sk-ant-api03-' + 'A'.repeat(250)
|
||||
await expect(saveWorkspaceKey(tooLong)).rejects.toThrow(
|
||||
/too long|exceed|256/,
|
||||
)
|
||||
})
|
||||
|
||||
test('error message does not contain high-entropy key suffix', async () => {
|
||||
const { saveWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
const badKey = 'sk-wrong-SECRETSECRET-' + 'A'.repeat(50)
|
||||
let thrownError: Error | null = null
|
||||
try {
|
||||
await saveWorkspaceKey(badKey)
|
||||
} catch (e) {
|
||||
thrownError = e as Error
|
||||
}
|
||||
expect(thrownError).not.toBeNull()
|
||||
// Error must not leak the high-entropy suffix
|
||||
expect(thrownError!.message).not.toContain('SECRETSECRET')
|
||||
expect(thrownError!.message).not.toContain('A'.repeat(50))
|
||||
})
|
||||
|
||||
test('removeWorkspaceKey deletes workspaceApiKey field via saveGlobalConfig', async () => {
|
||||
let captured: { workspaceApiKey?: string } | null = null
|
||||
_mockGetGlobalConfig = () => ({ workspaceApiKey: 'sk-ant-api03-EXISTING' })
|
||||
_mockSaveGlobalConfig = (updater: unknown) => {
|
||||
captured = (updater as (cur: { workspaceApiKey?: string }) => unknown)({
|
||||
workspaceApiKey: 'sk-ant-api03-EXISTING',
|
||||
}) as {
|
||||
workspaceApiKey?: string
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const { removeWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
await expect(removeWorkspaceKey()).resolves.toBeUndefined()
|
||||
expect(captured).not.toBeNull()
|
||||
const next = captured as unknown as { workspaceApiKey?: string }
|
||||
expect('workspaceApiKey' in next).toBe(false)
|
||||
})
|
||||
|
||||
test('removeWorkspaceKey wraps underlying error with sanitized message', async () => {
|
||||
_mockGetGlobalConfig = () => ({})
|
||||
_mockSaveGlobalConfig = () => {
|
||||
throw new Error('disk full at /tmp/x')
|
||||
}
|
||||
const { removeWorkspaceKey } = await import('../saveWorkspaceKey.js')
|
||||
await expect(removeWorkspaceKey()).rejects.toThrow(
|
||||
/Failed to remove workspace API key/,
|
||||
)
|
||||
})
|
||||
})
|
||||
95
src/services/auth/hostGuard.ts
Normal file
95
src/services/auth/hostGuard.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Host guard utilities for multi-auth routing.
|
||||
*
|
||||
* These guards enforce that workspace API key requests only go to Anthropic's
|
||||
* API host and that subscription OAuth requests stay on the subscription plane.
|
||||
* This prevents credential leakage to third-party hosts.
|
||||
*
|
||||
* Design: ~/.claude/rules/deep-debug/security.md §2 (read-only investigation first,
|
||||
* then minimal guard at earliest detection point).
|
||||
*/
|
||||
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
/** The canonical Anthropic API host for workspace (non-subscription) endpoints. */
|
||||
const WORKSPACE_API_HOST = 'api.anthropic.com'
|
||||
|
||||
/**
|
||||
* Asserts that `url` points to Anthropic's workspace API host.
|
||||
*
|
||||
* Called before every workspace API key request (agents, vaults, memory_stores,
|
||||
* skills) to prevent the API key from being sent to a third-party host.
|
||||
*
|
||||
* @throws {Error} if the URL does not resolve to api.anthropic.com
|
||||
*/
|
||||
export function assertWorkspaceHost(url: string): void {
|
||||
let hostname: string
|
||||
try {
|
||||
hostname = new URL(url).hostname
|
||||
} catch {
|
||||
throw new Error(
|
||||
`assertWorkspaceHost: invalid URL "${url}". Workspace API key requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (hostname !== WORKSPACE_API_HOST) {
|
||||
throw new Error(
|
||||
`assertWorkspaceHost: refusing to send workspace API key to non-Anthropic host "${hostname}". ` +
|
||||
`Workspace API key requests must target ${WORKSPACE_API_HOST}. ` +
|
||||
`If you are using a custom base URL, workspace endpoints are only available on the Anthropic API.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that `url` points to the Anthropic subscription base URL.
|
||||
*
|
||||
* Called before subscription-OAuth requests (schedule, ultrareview, teleport)
|
||||
* to ensure they only target the expected host. Less strict than assertWorkspaceHost —
|
||||
* it still allows the configured BASE_API_URL which may vary in test/staging.
|
||||
*
|
||||
* @throws {Error} if the URL does not resolve to api.anthropic.com
|
||||
*/
|
||||
export function assertSubscriptionBaseUrl(url: string): void {
|
||||
let hostname: string
|
||||
try {
|
||||
hostname = new URL(url).hostname
|
||||
} catch {
|
||||
throw new Error(
|
||||
`assertSubscriptionBaseUrl: invalid URL "${url}". Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (hostname !== WORKSPACE_API_HOST) {
|
||||
throw new Error(
|
||||
`assertSubscriptionBaseUrl: refusing subscription OAuth request to non-Anthropic host "${hostname}". ` +
|
||||
`Subscription OAuth requests must target ${WORKSPACE_API_HOST}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns (but does not throw) when Anthropic API environment variables are set
|
||||
* alongside OpenAI-compat configuration.
|
||||
*
|
||||
* This prevents silent credential confusion when a user has both
|
||||
* ANTHROPIC_API_KEY and OPENAI_API_KEY / CLAUDE_CODE_USE_OPENAI set.
|
||||
* The warning is informational — the calling code decides what to do.
|
||||
*/
|
||||
export function assertNoAnthropicEnvForOpenAI(): void {
|
||||
const hasOpenAIMode =
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] === '1' ||
|
||||
Boolean(process.env['OPENAI_API_KEY'])
|
||||
const hasAnthropicKey = Boolean(process.env['ANTHROPIC_API_KEY'])
|
||||
|
||||
if (hasOpenAIMode && hasAnthropicKey) {
|
||||
logError(
|
||||
new Error(
|
||||
'assertNoAnthropicEnvForOpenAI: Both ANTHROPIC_API_KEY and OpenAI-compat mode are set. ' +
|
||||
'ANTHROPIC_API_KEY is for Anthropic workspace endpoints (/v1/agents, /v1/vaults, /v1/memory_stores). ' +
|
||||
'OpenAI-compat mode routes /v1/messages to a third-party provider. ' +
|
||||
'These are separate credential planes and will not interfere, but verify this is intentional.',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
170
src/services/auth/saveWorkspaceKey.ts
Normal file
170
src/services/auth/saveWorkspaceKey.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* saveWorkspaceKey — saves a workspace API key to global config.
|
||||
*
|
||||
* Security properties:
|
||||
* - Validates sk-ant-api03- prefix before writing.
|
||||
* - Enforces minimum (20) and maximum (256) length limits.
|
||||
* - Error messages never contain the key value itself.
|
||||
* - After write, getGlobalConfig() immediately reflects the new key because
|
||||
* saveGlobalConfig uses write-through cache semantics.
|
||||
*
|
||||
* On POSIX: also attempts chmod 600 on the config file so only the owner can
|
||||
* read the plaintext key.
|
||||
* On Windows: no-op chmod, but a one-time warning is logged via logError.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import { getGlobalClaudeFile } from '../../utils/env.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKSPACE_KEY_PREFIX = 'sk-ant-api03-'
|
||||
const MIN_KEY_LENGTH = 20
|
||||
const MAX_KEY_LENGTH = 256
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates and saves a workspace API key to ~/.claude.json.
|
||||
*
|
||||
* The write is performed via saveGlobalConfig so the in-process cache is
|
||||
* updated immediately — no restart needed.
|
||||
*
|
||||
* @throws {Error} if the key is empty, has the wrong prefix, is too short, or
|
||||
* is too long. Error messages never expose the key value.
|
||||
* @throws {Error} (re-thrown) if the underlying fs write fails (sanitized).
|
||||
*/
|
||||
export async function saveWorkspaceKey(key: string): Promise<void> {
|
||||
// --- Validation (prefix-only, no key value in errors) ---
|
||||
if (!key || key.trim().length === 0) {
|
||||
throw new Error('Workspace API key must not be empty.')
|
||||
}
|
||||
|
||||
const trimmed = key.trim()
|
||||
|
||||
if (trimmed.length < MIN_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`Workspace API key is too short (${trimmed.length} chars). ` +
|
||||
`Expected at least ${MIN_KEY_LENGTH} chars starting with "${WORKSPACE_KEY_PREFIX}".`,
|
||||
)
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`Workspace API key is too long (${trimmed.length} chars). ` +
|
||||
`Maximum allowed length is ${MAX_KEY_LENGTH} chars.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith(WORKSPACE_KEY_PREFIX)) {
|
||||
// Only show first 4 chars of the actual key to avoid leaking entropy
|
||||
const prefix4 = trimmed.slice(0, 4)
|
||||
throw new Error(
|
||||
`Workspace API key must start with "${WORKSPACE_KEY_PREFIX}" (workspace key). ` +
|
||||
`Got prefix "${prefix4}...". ` +
|
||||
'Obtain a workspace API key from https://console.anthropic.com/settings/keys.',
|
||||
)
|
||||
}
|
||||
|
||||
// --- Write (cache-invalidating via saveGlobalConfig write-through) ---
|
||||
try {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
workspaceApiKey: trimmed,
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
// Sanitize: re-throw without mentioning the key value
|
||||
throw new Error(
|
||||
`Failed to save workspace API key to config: ${sanitizeErrorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- POSIX: chmod 600 the config file so only the owner can read it ---
|
||||
await tryChmod600()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the workspace API key from settings.
|
||||
* Does NOT touch the ANTHROPIC_API_KEY env var (that's session-scoped).
|
||||
*
|
||||
* After this, getEffectiveWorkspaceApiKey() will fall through to the env
|
||||
* var if any, otherwise return undefined.
|
||||
*/
|
||||
export async function removeWorkspaceKey(): Promise<void> {
|
||||
try {
|
||||
saveGlobalConfig(current => {
|
||||
// Strip the field; setting undefined preserves other properties.
|
||||
const next = { ...current }
|
||||
delete (next as { workspaceApiKey?: string }).workspaceApiKey
|
||||
return next
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`Failed to remove workspace API key: ${sanitizeErrorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective workspace API key from the two-source chain:
|
||||
* 1. ANTHROPIC_API_KEY env var (takes precedence)
|
||||
* 2. workspaceApiKey from ~/.claude.json
|
||||
*
|
||||
* Returns undefined when neither is set.
|
||||
*/
|
||||
export function getEffectiveWorkspaceApiKey(): string | undefined {
|
||||
const fromEnv = process.env['ANTHROPIC_API_KEY']?.trim()
|
||||
if (fromEnv) return fromEnv
|
||||
return getGlobalConfig().workspaceApiKey?.trim() || undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Strips any key-looking values from a raw error message so we never
|
||||
* accidentally surface the secret in error output / logs / Sentry.
|
||||
*/
|
||||
function sanitizeErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
// Replace any sk-ant-api03-* pattern with a placeholder
|
||||
return err.message.replace(/sk-ant-api03-\S*/g, '[REDACTED]')
|
||||
}
|
||||
return 'unknown error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to set mode 0o600 on the global config file.
|
||||
* - POSIX: silently succeeds or logs on failure.
|
||||
* - Windows: fs.chmod is a no-op; we log a one-time informational warning.
|
||||
*/
|
||||
async function tryChmod600(): Promise<void> {
|
||||
const configPath = getGlobalClaudeFile()
|
||||
if (process.platform === 'win32') {
|
||||
logError(
|
||||
new Error(
|
||||
'[saveWorkspaceKey] Windows: chmod 600 is not supported. ' +
|
||||
'To protect your API key, restrict access to ' +
|
||||
`${configPath} via icacls or Windows ACL settings.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fs.chmod(configPath, 0o600)
|
||||
} catch (err: unknown) {
|
||||
// Non-fatal — log but don't throw
|
||||
logError(
|
||||
new Error(
|
||||
`[saveWorkspaceKey] Could not set chmod 600 on ${configPath}: ${sanitizeErrorMessage(err)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user