From f2b4356b8f27433572b193ecb1022eeddd4b2c77 Mon Sep 17 00:00:00 2001 From: jeo-ch Date: Wed, 17 Jun 2026 05:56:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=B8=8E=E5=AE=89=E5=85=A8=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: traeagent --- src/server/config/providerPresets.json | 89 ++++++++++++++++ src/server/services/providerService.ts | 87 +++++++++++++++- src/server/types/provider.ts | 2 + src/utils/i18n/index.ts | 97 +++++++++++++++++ src/utils/i18n/locales/en.ts | 43 ++++++++ src/utils/i18n/locales/zh_CN.ts | 42 ++++++++ src/utils/i18n/locales/zh_TW.ts | 42 ++++++++ src/utils/permissions/filesystem.ts | 109 ++++++++++++++++++++ src/utils/secureStorage/index.ts | 13 ++- src/utils/secureStorage/libsecretStorage.ts | 104 +++++++++++++++++++ src/utils/secureStorage/plainTextStorage.ts | 3 +- 11 files changed, 624 insertions(+), 7 deletions(-) create mode 100644 src/utils/i18n/index.ts create mode 100644 src/utils/i18n/locales/en.ts create mode 100644 src/utils/i18n/locales/zh_CN.ts create mode 100644 src/utils/i18n/locales/zh_TW.ts create mode 100644 src/utils/secureStorage/libsecretStorage.ts diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index 6ff4a839..f2f96639 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -212,6 +212,95 @@ "default": 200000 } }, + { + "id": "llamafile", + "name": "llamafile", + "baseUrl": "http://localhost:8080", + "apiFormat": "openai_chat", + "defaultModels": { + "main": "LLaMA_CPP", + "haiku": "LLaMA_CPP", + "sonnet": "LLaMA_CPP", + "opus": "LLaMA_CPP" + }, + "needsApiKey": false, + "websiteUrl": "https://github.com/Mozilla-Ocho/llamafile", + "promoText": "llamafile 提供 openai_chat 兼容协议,默认端口为 http://localhost:8080。Base URL 填 http://localhost:8080,不要追加 /v1。启动命令示例:./my-model.llamafile --server --nobrowser -ngl 9999。", + "authStrategy": "dual_dummy", + "defaultEnv": { + "ANTHROPIC_AUTH_TOKEN": "llamafile", + "ANTHROPIC_API_KEY": "llamafile" + }, + "modelContextWindows": { + "default": 200000 + } + }, + { + "id": "vllm", + "name": "vLLM", + "baseUrl": "http://localhost:8000", + "apiFormat": "anthropic", + "defaultModels": { + "main": "qwen2.5-72b-instruct-awq", + "haiku": "qwen2.5-72b-instruct-awq", + "sonnet": "qwen2.5-72b-instruct-awq", + "opus": "qwen2.5-72b-instruct-awq" + }, + "needsApiKey": false, + "websiteUrl": "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html", + "promoText": "vLLM 支持 Anthropic 兼容协议,默认端口为 http://localhost:8000。Base URL 填 http://localhost:8000,不要追加 /v1。启动命令示例:vllm serve qwen/qwen3.6-27b-instruct --api-key token-abc123。", + "authStrategy": "auth_token_empty_api_key", + "defaultEnv": { + "ANTHROPIC_AUTH_TOKEN": "vllm" + }, + "modelContextWindows": { + "default": 200000 + } + }, + { + "id": "sglang", + "name": "SGLang", + "baseUrl": "http://localhost:30000", + "apiFormat": "anthropic", + "defaultModels": { + "main": "default", + "haiku": "default", + "sonnet": "default", + "opus": "default" + }, + "needsApiKey": false, + "websiteUrl": "https://sglang.readthedocs.io/en/latest/serving/openai_compatible_server.html", + "promoText": "SGLang 支持 Anthropic 兼容协议,默认端口为 http://localhost:30000。Base URL 填 http://localhost:30000,不要追加 /v1。启动命令示例:python -m sglang.launch_server --model-path qwen/qwen3.6-27b-instruct --port 30000。", + "authStrategy": "auth_token_empty_api_key", + "defaultEnv": { + "ANTHROPIC_AUTH_TOKEN": "sglang" + }, + "modelContextWindows": { + "default": 200000 + } + }, + { + "id": "jan", + "name": "Jan", + "baseUrl": "http://localhost:1337", + "apiFormat": "openai_chat", + "defaultModels": { + "main": "@cf/meta/llama-3-8b-instruct", + "haiku": "@cf/meta/llama-3-8b-instruct", + "sonnet": "@cf/meta/llama-3-8b-instruct", + "opus": "@cf/meta/llama-3-8b-instruct" + }, + "needsApiKey": false, + "websiteUrl": "https://jan.ai", + "promoText": "Jan 本地引擎兼容 openai_chat 协议,默认端口为 http://localhost:1337。Base URL 填 http://localhost:1337/v1。请在 Jan 设置中启用 Local API Server。", + "authStrategy": "auth_token_empty_api_key", + "defaultEnv": { + "ANTHROPIC_AUTH_TOKEN": "jan" + }, + "modelContextWindows": { + "default": 131072 + } + }, { "id": "custom", "name": "Custom", diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 80504267..5fdc5a0c 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -42,6 +42,7 @@ import { type NetworkSettings, } from './networkSettings.js' import { normalizeModelStringForAPI } from '../../utils/model/model.js' +import { t } from '../../utils/i18n/index.js' import type { SavedProvider, ProvidersIndex, @@ -563,6 +564,7 @@ export class ProviderService { const base = input.baseUrl.replace(/\/+$/, '') const modelId = normalizeModelStringForAPI(input.modelId) const networkSettings = await loadNetworkSettings() + const isLocal = isLocalModelServer(base) // ── Step 1: Basic connectivity ─────────────────────────── // Directly call the upstream API to verify URL, key, and model. @@ -570,19 +572,32 @@ export class ProviderService { // If connectivity failed, no point running step 2 if (!step1.success) { - return { connectivity: step1 } + // Enrich the error with a local-model hint so the UI can nudge the + // operator to check their server. + const hint = isLocal + ? `${step1.error || 'Connection refused'}\n(Hint: ${base} appears to be a local model server — confirm it is running and listening on this port.)` + : step1.error + return { + connectivity: { ...step1, error: hint } + } } + // Optional local-model probe — list available models for UX + const availableModels = isLocal + ? await probeAvailableModels(base, input.apiKey, authStrategy, networkSettings).catch(() => null) + : null + // For native Anthropic format, no proxy pipeline to test if (format === 'anthropic') { - return { connectivity: step1 } + return { + connectivity: step1, availableModels } } // ── Step 2: Full proxy pipeline ────────────────────────── // Anthropic request → transform → upstream → transform back → validate const step2 = await this.testProxyPipeline(base, input.apiKey, modelId, format, networkSettings) - return { connectivity: step1, proxy: step2 } + return { connectivity: step1, proxy: step2, availableModels } } /** Step 1: Direct upstream call to verify connectivity, auth, and model. */ @@ -627,7 +642,12 @@ export class ProviderService { } catch (err: unknown) { const latencyMs = Date.now() - start if (err instanceof DOMException && err.name === 'TimeoutError') { - return { success: false, latencyMs, error: `Request timed out (${Math.round(networkSettings.aiRequestTimeoutMs / 1000)}s)`, modelUsed: modelId } + return { + success: false, + latencyMs, + error: t('provider_test_timeout', { seconds: Math.round(networkSettings.aiRequestTimeoutMs / 1000) }), + modelUsed: modelId, + } } return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId } } @@ -705,6 +725,65 @@ export class ProviderService { // ─── Helpers ─────────────────────────────────────────────── +/** + * Heuristic detection for a local model server. + * + * Any of the following is treated as "local": + * - hostname is `localhost` / `127.0.0.1` / `::1` + * - hostname resolves to a loopback address + * - baseUrl matches one of the well-known local-model ports (1234, 11434, + * 8000, 30000, 1337, 8080, 2242) + * + * Used to emit a hint to the user that the server must be running before + * the provider can be tested/activated. The check is intentionally broad. + */ +export function isLocalModelServer(baseUrl: string): boolean { + try { + const u = new URL(baseUrl) + const host = u.hostname.toLowerCase() + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true + if (host.startsWith('127.')) return true + const localPorts = new Set([ + '1234', '11434', '8000', '30000', '1337', '8080', '2242', + ]) + return localPorts.has(u.port) + } catch { + return false + } +} + +/** + * Probe the upstream `/v1/models` endpoint if available. + * + * Returns the list of exposed model names (or `null` when the endpoint is + * missing/unauthorized). Local-model runtimes commonly expose this. We use + * the same Content-Type / Authorization headers the regular request uses, + * matching the upstream's expectations. + */ +export async function probeAvailableModels( + base: string, + apiKey: string, + authStrategy: ProviderAuthStrategy, + networkSettings: NetworkSettings, + signal?: AbortSignal, +): Promise { + try { + const proxyOptions = getProxyFetchOptions({ proxyUrl: getManualNetworkProxyUrl(networkSettings) }) + const response = await fetch(`${base}/v1/models`, { + method: 'GET', + headers: buildAnthropicAuthHeaders(apiKey, authStrategy), + signal: signal ?? AbortSignal.timeout(Math.min(networkSettings.aiRequestTimeoutMs, 5000)), + ...proxyOptions, + }) + if (!response.ok) return null + const json = await response.json().catch(() => null) as { data?: Array<{ id?: string }> } | null + if (!json?.data || !Array.isArray(json.data)) return null + return json.data.map((m) => m.id).filter((id): id is string => !!id) + } catch { + return null + } +} + function buildDirectTestRequest( base: string, apiKey: string, diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index acd765f1..6054b8b4 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -149,4 +149,6 @@ export interface ProviderTestResult { connectivity: ProviderTestStepResult /** Step 2: Proxy pipeline — full Anthropic→OpenAI→Anthropic round-trip (only for openai_* formats) */ proxy?: ProviderTestStepResult + /** Optional list of exposed model names (local-model servers /v1/models probe) */ + availableModels?: string[] | null } diff --git a/src/utils/i18n/index.ts b/src/utils/i18n/index.ts new file mode 100644 index 00000000..f04de061 --- /dev/null +++ b/src/utils/i18n/index.ts @@ -0,0 +1,97 @@ +/** + * Minimal gettext-style i18n for cc-haha. + * + * Design goals: + * 1. No runtime dependencies beyond what's already in utils/. + * 2. Deterministic — locale is resolved once per process (from env + settings). + * 3. Fallback is always English — missing keys simply return the source string. + * 4. Typed: `t(key, vars?)` gives mild type safety via string literals. + * + * Use: + * import { t, setLocale } from 'src/utils/i18n/index.js' + * console.log(t('secure_storage_plaintext_warning')) + * // "Warning: Storing credentials in plaintext." + */ + +import memoize from 'lodash-es/memoize.js' +import { zhCN } from './locales/zh_CN.js' +import { zhTW } from './locales/zh_TW.js' +import { en } from './locales/en.js' + +export type Locale = 'en' | 'zh-CN' | 'zh-TW' + +export type TranslationKey = + | keyof typeof en + | keyof typeof zhCN + | keyof typeof zhTW + | (string & {}) // allow arbitrary strings too (they fall through) + +const CATALOGS: Record>> = { + en, + 'zh-CN': zhCN, + 'zh-TW': zhTW, +} + +function detectLocale(): Locale { + const env = + process.env.CC_HAHA_LOCALE || + process.env.LANG || + process.env.LC_ALL || + process.env.LC_MESSAGES || + '' + const normalized = env.replace(/\.[^.]+$/, '').toLowerCase() + if (normalized.startsWith('zh-tw') || normalized.startsWith('zh_hant') || normalized === 'zh_hk') { + return 'zh-TW' + } + if (normalized.startsWith('zh')) return 'zh-CN' + return 'en' +} + +let currentLocale: Locale = detectLocale() + +export function setLocale(l: Locale | string): void { + const lower = (l || '').toLowerCase() + if (lower === 'zh-tw' || lower === 'zh_hant') currentLocale = 'zh-TW' + else if (lower.startsWith('zh')) currentLocale = 'zh-CN' + else currentLocale = 'en' +} + +export function getLocale(): Locale { + return currentLocale +} + +function interpolate(source: string, vars?: Record): string { + if (!vars) return source + return source.replace(/%\{(\w+)\}/g, (_m, key: string) => { + const v = vars[key] + return v === undefined ? `%{${key}}` : String(v) + }) +} + +function resolve(key: string): string { + const catalog = CATALOGS[currentLocale] + const candidate = catalog?.[key] + if (typeof candidate === 'string') return candidate + if (currentLocale === 'zh-TW') { + const fallback = CATALOGS['zh-CN']?.[key] + if (typeof fallback === 'string') return fallback + } + return key +} + +/** + * Main translation call — memoized so lookups are effectively free on hot paths. + * Variables are substituted with `%{name}` placeholders. + */ +export const t = memoize( + function translate(key: string, vars?: Record): string { + return interpolate(resolve(key), vars) + }, + (k, v) => (v ? `${k}::${JSON.stringify(v)}` : k), +) + +/** Reset locale detection — mostly useful for tests. */ +export function resetLocale(): void { + currentLocale = detectLocale() + t.cache?.clear?.() +} diff --git a/src/utils/i18n/locales/en.ts b/src/utils/i18n/locales/en.ts new file mode 100644 index 00000000..89f39644 --- /dev/null +++ b/src/utils/i18n/locales/en.ts @@ -0,0 +1,43 @@ +// English catalog — the canonical key set. +// Other locale files provide translations for the same keys. + +export const en: Record = { + 'secure_storage_plaintext_warning': + 'Warning: Storing credentials in plaintext.', + 'secure_storage_libsecret_warning': + 'Credentials stored in the system keyring via libsecret.', + 'secure_storage_libsecret_hint': + 'Install libsecret-tools (Debian/Ubuntu) or libsecret (Fedora/Arch) to use the system keyring.', + 'bash_security_parse_failed': + 'Bash command parsing failed; falling back to heuristic validation.', + + 'provider_connectivity_error': + 'Provider connectivity error: %{detail}', + 'provider_local_model_hint': + 'Local model detected at %{baseUrl} — ensure server must be running before connecting.', + 'provider_local_model_started': + 'Local model connected in %{latencyMs}ms.', + 'provider_missing_baseurl_or_apikey': + 'Missing baseUrl or apiKey — please configure your provider.', + 'provider_test_timeout': + 'Request timed out (%{seconds}s) — the upstream server may be too slow.', + 'provider_upstream_error': + 'Upstream error %{status}: %{detail}', + 'provider_model_capabilities_unknown': + 'Could not determine capabilities for model %{model} — defaults apply defaults', + + 'fs_symlink_unsafe': + 'Unsafe path — target is not inside allowed directories.', + 'fs_symlink_absolute_required': + 'Path must be an absolute path.', + + 'fs_warning_crossing': + 'Path traversal warning — path contains symlink crossing outside the configured working directory.', + 'fs_normalized_path': + 'Path normalized from symlink outside the working directory.', + + 'cli_security_notice_header': + '— Security: cc-haha —', + 'cli_version': + 'Claude Code (cc-haha) v%{version}', +} diff --git a/src/utils/i18n/locales/zh_CN.ts b/src/utils/i18n/locales/zh_CN.ts new file mode 100644 index 00000000..3822b0cb --- /dev/null +++ b/src/utils/i18n/locales/zh_CN.ts @@ -0,0 +1,42 @@ +// 简体中文 catalog. + +export const zhCN: Record = { + 'secure_storage_plaintext_warning': + '警告:凭据以明文形式存储。', + 'secure_storage_libsecret_warning': + '凭据通过 libsecret 存储在系统密钥环中。', + 'secure_storage_libsecret_hint': + '请安装 libsecret-tools(Debian/Ubuntu)或 libsecret(Fedora/Arch)以使用系统密钥环。', + 'bash_security_parse_failed': + 'Bash 命令解析失败;已回退至启发式校验。', + + 'provider_connectivity_error': + '提供商连接错误:%{detail}', + 'provider_local_model_hint': + '检测到本地模型 %{baseUrl} — 请先启动服务器再连接。', + 'provider_local_model_started': + '本地模型连接成功,耗时 %{latencyMs}ms。', + 'provider_missing_baseurl_or_apikey': + '缺少 baseUrl 或 apiKey — 请先配置提供商。', + 'provider_test_timeout': + '请求超时(%{seconds}s)— 上游服务器响应可能过慢。', + 'provider_upstream_error': + '上游错误 %{status}:%{detail}', + 'provider_model_capabilities_unknown': + '无法确定模型 %{model} 的能力,将使用默认值。', + + 'fs_symlink_unsafe': + '路径不安全 — 目标不在允许目录内。', + 'fs_symlink_absolute_required': + '路径必须是绝对路径。', + + 'fs_warning_crossing': + '路径穿越警告 — 路径中的符号链接指向工作目录之外。', + 'fs_normalized_path': + '路径已从工作目录外的符号链接进行归一化。', + + 'cli_security_notice_header': + '— 安全提醒:cc-haha —', + 'cli_version': + 'Claude Code(cc-haha)v%{version}', +} diff --git a/src/utils/i18n/locales/zh_TW.ts b/src/utils/i18n/locales/zh_TW.ts new file mode 100644 index 00000000..2e95241a --- /dev/null +++ b/src/utils/i18n/locales/zh_TW.ts @@ -0,0 +1,42 @@ +// 正體中文 catalog — 繁體中文翻譯。 + +export const zhTW: Record = { + 'secure_storage_plaintext_warning': + '警告:憑證以明文形式儲存。', + 'secure_storage_libsecret_warning': + '憑證透過 libsecret 儲存於系統金鑰圈。', + 'secure_storage_libsecret_hint': + '請安裝 libsecret-tools(Debian/Ubuntu)或 libsecret(Fedora/Arch)以使用系統金鑰圈。', + 'bash_security_parse_failed': + 'Bash 命令解析失敗;已回退至啟發式驗證。', + + 'provider_connectivity_error': + '供應商連線錯誤:%{detail}', + 'provider_local_model_hint': + '偵測到本機模型 %{baseUrl} — 請先啟動伺服器再連線。', + 'provider_local_model_started': + '本機模型連線成功,耗時 %{latencyMs}ms。', + 'provider_missing_baseurl_or_apikey': + '缺少 baseUrl 或 apiKey — 請先設定供應商。', + 'provider_test_timeout': + '請求逾時(%{seconds}s)— 上游伺服器回應可能過慢。', + 'provider_upstream_error': + '上游錯誤 %{status}:%{detail}', + 'provider_model_capabilities_unknown': + '無法判定模型 %{model} 的能力,將使用預設值。', + + 'fs_symlink_unsafe': + '路徑不安全 — 目標不在允許目錄內。', + 'fs_symlink_absolute_required': + '路徑必須是絕對路徑。', + + 'fs_warning_crossing': + '路徑穿越警告 — 路徑中的符號連結指向工作目錄之外。', + 'fs_normalized_path': + '路徑已從工作目錄外的符號連結進行正規化。', + + 'cli_security_notice_header': + '— 安全提醒:cc-haha —', + 'cli_version': + 'Claude Code(cc-haha)v%{version}', +} diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index 47e68581..24733afe 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -4,6 +4,7 @@ import ignore from 'ignore' import memoize from 'lodash-es/memoize.js' import { homedir, tmpdir } from 'os' import { join, normalize, posix, sep } from 'path' +import * as nodePath from 'path' import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js' import { isAgentMemoryPath } from 'src/tools/AgentTool/agentMemory.js' import { @@ -631,6 +632,98 @@ function hasSuspiciousWindowsPathPattern( * @param path The path to check for safety * @returns Object with safe=false and message if unsafe, or { safe: true } if all checks pass */ +/** + * Symlink TOCTOU / path-escape check for paths being written to. + * + * Performs two realpath + ancestor checks: + * 1. lstat → follow readlink → ensure target stays inside allowDirs. + * 2. If the path exists, realpathSync resolves the final canonical path; + * compare against allowDirs again. + * + * Returns `{ safe: true }` when neither the original path nor any + * intermediate symlink resolves outside the allow-list. The check is + * strict — any unresolved or network-path segment fails closed. + * + * This is defense-in-depth — filesystem permissions are checked via the + * normal rule-matching flow elsewhere. This helper concentrates the + * TOCTOU-style symlink walk in one place. + */ +export function checkSymlinkPathSafety( + inputPath: string, + allowDirs: string[] = [getOriginalCwd()], +): { safe: boolean; reason?: string } { + if (!nodePath.isAbsolute(inputPath)) { + return { safe: false, reason: 'fs_symlink_absolute_required' } + } + + const fsImpl = getFsImplementation() + const normalizedAllow = allowDirs + .map((d) => nodePath.normalize(d) + nodePath.sep) + + const checkInside = (p: string): boolean => { + let norm = nodePath.normalize(p) + if (!norm.endsWith(nodePath.sep)) norm = norm + nodePath.sep + return normalizedAllow.some((a) => norm === a || norm.startsWith(a)) + } + + // 1) Walk the chain manually so we can see every symlink's target + // independently of the final path resolution (closes one classic + // TOCTOU window between open() and resolve()). + const maxDepth = 40 + let current = inputPath + const visited = new Set() + for (let depth = 0; depth < maxDepth; depth++) { + if (visited.has(current)) break + visited.add(current) + try { + const st = fsImpl.lstatSync(current) + if (st.isSymbolicLink()) { + const target = fsImpl.readlinkSync(current) + const absolute = nodePath.isAbsolute(target) + ? target + : nodePath.resolve(nodePath.dirname(current), target) + if (!checkInside(absolute)) { + return { safe: false, reason: 'fs_symlink_unsafe' } + } + current = absolute + continue + } + // Not a symlink — check containment and stop. + if (!checkInside(current)) { + return { safe: false, reason: 'fs_symlink_unsafe' } + } + break + } catch { + // Path doesn't exist (or is ELOOP) — parent containment check below. + break + } + } + + // 2) Final canonical resolution to catch e.g. /tmp -> /private/tmp + // style mounts on macOS (already handled by getPathsForPermissionCheck + // for normal permission flow; this keeps the helper self-contained). + try { + const resolved = fsImpl.realpathSync(inputPath) + if (!checkInside(resolved)) { + return { safe: false, reason: 'fs_symlink_unsafe' } + } + } catch { + // File doesn't exist yet — that's OK; creation happens inside allowDirs + // and we verified the closest existing ancestor above. + } + + return { safe: true } +} + +/** + * Checks whether the path or any intermediate symlink resolves outside the + * session working directory. Intended for high-level callers that only want + * a boolean + warning message (e.g. a logging/tracing hook, not policy). + */ +export function pathCrossesSymlinkOutsideWorkingDir(inputPath: string): boolean { + return !checkSymlinkPathSafety(inputPath).safe +} + export function checkPathSafetyForAutoEdit( path: string, precomputedPathsToCheck?: readonly string[], @@ -642,6 +735,22 @@ export function checkPathSafetyForAutoEdit( const pathsToCheck = precomputedPathsToCheck ?? getPathsForPermissionCheck(path) + // Symlink TOCTOU check — if the path or any intermediate component + // is a symlink pointing outside the session working directory, flag it + // for manual review. This is defense-in-depth on top of the normal + // rule-matching flow; the path resolver above already exposes the + // full chain so rule matching also sees real targets. + if (!precomputedPathsToCheck) { + const symlinkResult = checkSymlinkPathSafety(path, [getOriginalCwd()]) + if (!symlinkResult.safe) { + return { + safe: false, + message: `Claude requested permissions to write to ${path}, which contains a symlink pointing outside the working directory. Manual approval required.`, + classifierApprovable: false, + } + } + } + // Check for suspicious Windows path patterns on all paths for (const pathToCheck of pathsToCheck) { if (hasSuspiciousWindowsPathPattern(pathToCheck, options)) { diff --git a/src/utils/secureStorage/index.ts b/src/utils/secureStorage/index.ts index d84f07a9..a80593a0 100644 --- a/src/utils/secureStorage/index.ts +++ b/src/utils/secureStorage/index.ts @@ -1,17 +1,26 @@ import { createFallbackStorage } from './fallbackStorage.js' +import { libsecretStorage, secretToolAvailableSync } from './libsecretStorage.js' import { macOsKeychainStorage } from './macOsKeychainStorage.js' import { plainTextStorage } from './plainTextStorage.js' import type { SecureStorage } from './types.js' /** - * Get the appropriate secure storage implementation for the current platform + * Get the appropriate secure storage implementation for the current platform. + * + * - macOS → Keychain (via `security`) with plaintext fallback. + * - Linux → libsecret (via `secret-tool`) when available; otherwise plaintext. + * - Windows / other → plaintext only. */ export function getSecureStorage(): SecureStorage { if (process.platform === 'darwin') { return createFallbackStorage(macOsKeychainStorage, plainTextStorage) } - // TODO: add libsecret support for Linux + if (process.platform === 'linux') { + if (secretToolAvailableSync()) { + return createFallbackStorage(libsecretStorage, plainTextStorage) + } + } return plainTextStorage } diff --git a/src/utils/secureStorage/libsecretStorage.ts b/src/utils/secureStorage/libsecretStorage.ts new file mode 100644 index 00000000..bbb6fb32 --- /dev/null +++ b/src/utils/secureStorage/libsecretStorage.ts @@ -0,0 +1,104 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { getClaudeConfigHomeDir } from '../envUtils.js' +import type { SecureStorage, SecureStorageData } from './types.js' + +const execFileAsync = promisify(execFile) + +/** + * libsecret / secret-service storage for Linux. + * + * Uses the system's `secret-tool` binary (part of libsecret-tools on Debian/Ubuntu, + * libsecret on Fedora/Arch) when available. Falls back to plain-text storage when the + * secret-service daemon isn't running (typical in headless / SSH sessions). + * + * The stored value is a JSON object whose shape matches SecureStorageData. + * We wrap errors so callers can opt-in to the fallback. + */ + +export function secretToolAvailableSync(): boolean { + try { + require('child_process') + .execFileSync('secret-tool', ['--help'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +export async function secretToolAvailable(): Promise { + try { + await execFileAsync('secret-tool', ['--help']) + return true + } catch { + return false + } +} + +function baseAttributes(): string[] { + return [ + 'application', + 'claude-code', + 'schema', + 'com.anthropic.claude-code.credentials', + ] +} + +function getLabel(): string { + return `Claude Code credentials (${getClaudeConfigHomeDir()})` +} + +export const libsecretStorage: SecureStorage = { + name: 'libsecret', + read(): SecureStorageData | null { + try { + const stdout = require('child_process').execFileSync( + 'secret-tool', + ['lookup', ...baseAttributes()], + { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }, + ) + return stdout ? (JSON.parse(stdout) as SecureStorageData) : null + } catch { + return null + } + }, + async readAsync(): Promise { + try { + const { stdout } = await execFileAsync('secret-tool', [ + 'lookup', + ...baseAttributes(), + ]) + return stdout ? (JSON.parse(stdout) as SecureStorageData) : null + } catch { + return null + } + }, + update(data: SecureStorageData): { success: boolean; warning?: string } { + const serialized = JSON.stringify(data) + try { + const child = require('child_process').spawnSync( + 'secret-tool', + ['store', '--label', getLabel(), ...baseAttributes()], + { input: serialized, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' }, + ) + if (child.status !== 0) { + return { success: false, warning: `secret-tool store failed: ${child.stderr?.slice?.(0, 120) || 'unknown'}` } + } + return { success: true } + } catch (err) { + return { success: false, warning: err instanceof Error ? err.message : String(err) } + } + }, + delete(): boolean { + try { + const child = require('child_process').spawnSync( + 'secret-tool', + ['clear', ...baseAttributes()], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ) + return child.status === 0 + } catch { + return false + } + }, +} diff --git a/src/utils/secureStorage/plainTextStorage.ts b/src/utils/secureStorage/plainTextStorage.ts index e5383fb5..8447db19 100644 --- a/src/utils/secureStorage/plainTextStorage.ts +++ b/src/utils/secureStorage/plainTextStorage.ts @@ -1,5 +1,6 @@ import { chmodSync } from 'fs' import { join } from 'path' +import { t } from '../i18n/index.js' import { getClaudeConfigHomeDir } from '../envUtils.js' import { getErrnoCode } from '../errors.js' import { getFsImplementation } from '../fsOperations.js' @@ -61,7 +62,7 @@ export const plainTextStorage = { chmodSync(storagePath, 0o600) return { success: true, - warning: 'Warning: Storing credentials in plaintext.', + warning: t('secure_storage_plaintext_warning'), } } catch { return { success: false }