mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
feat: 项目代码逻辑与安全分析
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
This commit is contained in:
parent
1fbe385682
commit
f2b4356b8f
@ -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",
|
||||
|
||||
@ -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<string[] | null> {
|
||||
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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
97
src/utils/i18n/index.ts
Normal file
97
src/utils/i18n/index.ts
Normal file
@ -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<Locale, Partial<Record<string, string>>> = {
|
||||
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, string | number>): 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, string | number>): 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?.()
|
||||
}
|
||||
43
src/utils/i18n/locales/en.ts
Normal file
43
src/utils/i18n/locales/en.ts
Normal file
@ -0,0 +1,43 @@
|
||||
// English catalog — the canonical key set.
|
||||
// Other locale files provide translations for the same keys.
|
||||
|
||||
export const en: Record<string, string> = {
|
||||
'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}',
|
||||
}
|
||||
42
src/utils/i18n/locales/zh_CN.ts
Normal file
42
src/utils/i18n/locales/zh_CN.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// 简体中文 catalog.
|
||||
|
||||
export const zhCN: Record<string, string> = {
|
||||
'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}',
|
||||
}
|
||||
42
src/utils/i18n/locales/zh_TW.ts
Normal file
42
src/utils/i18n/locales/zh_TW.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// 正體中文 catalog — 繁體中文翻譯。
|
||||
|
||||
export const zhTW: Record<string, string> = {
|
||||
'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}',
|
||||
}
|
||||
@ -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<string>()
|
||||
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)) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
104
src/utils/secureStorage/libsecretStorage.ts
Normal file
104
src/utils/secureStorage/libsecretStorage.ts
Normal file
@ -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<boolean> {
|
||||
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<SecureStorageData | null> {
|
||||
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
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user