From 993077c60c0f2955c036948b68f31e48fe650fab Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Jul 2026 15:15:46 +0800 Subject: [PATCH 01/12] chore(presets): migrate AIGoCode sponsor domain to .app Full domain migration aigocode.com -> aigocode.app (bare domain + api. subdomain, invite path unchanged) across all 8 preset files and 4 README languages. Icons, promotion keys, and i18n copy unchanged. --- README.md | 4 ++-- README_DE.md | 4 ++-- README_JA.md | 4 ++-- README_ZH.md | 4 ++-- src/config/claudeDesktopProviderPresets.ts | 8 ++++---- src/config/claudeProviderPresets.ts | 8 ++++---- src/config/codexProviderPresets.ts | 8 ++++---- src/config/geminiProviderPresets.ts | 10 +++++----- src/config/grokBuildProviderPresets.ts | 8 ++++---- src/config/hermesProviderPresets.ts | 6 +++--- src/config/openclawProviderPresets.ts | 6 +++--- src/config/opencodeProviderPresets.ts | 6 +++--- 12 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a0decad3f4..4ad26905ad 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,8 @@ Register now via this lin -AIGoCode -Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! +AIGoCode +Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! diff --git a/README_DE.md b/README_DE.md index cb12d866a7..ca979fdd4a 100644 --- a/README_DE.md +++ b/README_DE.md @@ -83,8 +83,8 @@ Registrieren Sie sich jetzt über AIGoCode -Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über diesen Link registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben! +AIGoCode +Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über diesen Link registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben! diff --git a/README_JA.md b/README_JA.md index 5b13048b3f..c20ccf19a0 100644 --- a/README_JA.md +++ b/README_JA.md @@ -83,8 +83,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / -AIGoCode -本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! +AIGoCode +本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! diff --git a/README_ZH.md b/README_ZH.md index eec28f01b3..fddb26095b 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -83,8 +83,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 -AIGoCode -感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! +AIGoCode +感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index d28be24dfc..268d3a4761 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -311,14 +311,14 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", category: "third_party", - baseUrl: "https://api.aigocode.com", + baseUrl: "https://api.aigocode.app", mode: "direct", apiFormat: "anthropic", modelRoutes: passthroughRoutes(), - endpointCandidates: ["https://api.aigocode.com"], + endpointCandidates: ["https://api.aigocode.app"], isPartner: true, partnerPromotionKey: "aigocode", icon: "aigocode", diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index c05b93fb5a..2e421dc9a8 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -283,16 +283,16 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", settingsConfig: { env: { - ANTHROPIC_BASE_URL: "https://api.aigocode.com", + ANTHROPIC_BASE_URL: "https://api.aigocode.app", ANTHROPIC_AUTH_TOKEN: "", }, }, // 请求地址候选(用于地址管理/测速) - endpointCandidates: ["https://api.aigocode.com"], + endpointCandidates: ["https://api.aigocode.app"], category: "third_party", isPartner: true, // 合作伙伴 partnerPromotionKey: "aigocode", // 促销信息 i18n key diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 508c04032a..b0b2916bb1 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -349,16 +349,16 @@ requires_openai_auth = true`, }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", category: "third_party", auth: generateThirdPartyAuth(""), config: generateThirdPartyConfig( "aigocode", - "https://api.aigocode.com", + "https://api.aigocode.app", "gpt-5.6-sol", ), - endpointCandidates: ["https://api.aigocode.com"], + endpointCandidates: ["https://api.aigocode.app"], isPartner: true, // 合作伙伴 partnerPromotionKey: "aigocode", // 促销信息 i18n key icon: "aigocode", diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index fb6427e64e..d1cd6b88ff 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -150,21 +150,21 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", settingsConfig: { env: { - GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com", + GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.app", GEMINI_MODEL: "gemini-3.6-flash", }, }, - baseURL: "https://api.aigocode.com", + baseURL: "https://api.aigocode.app", model: "gemini-3.6-flash", description: "AIGoCode", category: "third_party", isPartner: true, partnerPromotionKey: "aigocode", - endpointCandidates: ["https://api.aigocode.com"], + endpointCandidates: ["https://api.aigocode.app"], icon: "aigocode", iconColor: "#5B7FFF", }, diff --git a/src/config/grokBuildProviderPresets.ts b/src/config/grokBuildProviderPresets.ts index ed06e23660..ff211f92f4 100644 --- a/src/config/grokBuildProviderPresets.ts +++ b/src/config/grokBuildProviderPresets.ts @@ -198,11 +198,11 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", auth: grokAuth(), - config: grokPresetConfig("AIGoCode", "https://api.aigocode.com"), - endpointCandidates: ["https://api.aigocode.com"], + config: grokPresetConfig("AIGoCode", "https://api.aigocode.app"), + endpointCandidates: ["https://api.aigocode.app"], category: "third_party", isPartner: true, partnerPromotionKey: "aigocode", diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index b4b78c1cf6..debacc74c5 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -356,11 +356,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", settingsConfig: { name: "aigocode", - base_url: "https://api.aigocode.com", + base_url: "https://api.aigocode.app", api_key: "", api_mode: "anthropic_messages", models: [ diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 104ac292fc..50c81db202 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -514,10 +514,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", settingsConfig: { - baseUrl: "https://api.aigocode.com", + baseUrl: "https://api.aigocode.app", apiKey: "", api: "anthropic-messages", models: [ diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 4140dc5ba9..3bc18448c7 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -591,13 +591,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "AIGoCode", - websiteUrl: "https://aigocode.com", - apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + websiteUrl: "https://aigocode.app", + apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH", settingsConfig: { npm: "@ai-sdk/anthropic", name: "AIGoCode", options: { - baseURL: "https://api.aigocode.com", + baseURL: "https://api.aigocode.app", apiKey: "", setCacheKey: true, }, From c98913df417254175fe54a18968b125c96e36110 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Jul 2026 22:46:44 +0800 Subject: [PATCH 02/12] fix(database): reject cross-file statements during SQL import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import_sql_string_inner` validated only that the file starts with the `-- CC Switch SQLite 导出` comment, then handed the whole text to `execute_batch`. Anything after that prefix ran unchecked, so a crafted backup could `ATTACH DATABASE '/path/x.db'` and create a SQLite file anywhere the user can write. The side effect lands before `validate_basic_state`, so the file survives even when the import as a whole fails. `settings` is in neither SYNC_SKIP_TABLES nor SYNC_PRESERVE_TABLES, so the WebDAV/S3 sync path reaches the same code. Install a SQLite authorizer for the duration of the external batch only, then clear it so our own schema maintenance is unaffected. Deny what can leave the temp database rather than allow-listing what `dump_sql` emits. The batch runs on a throwaway NamedTempFile whose entire contents are already decided by that same SQL, so DELETE/DROP/ UPDATE hand an attacker nothing new -- the only meaningful boundary is the temp file itself. A strict allow-list only adds the risk of refusing a legitimate backup whose schema has a shape we did not anticipate. The denied set was measured, not guessed: `ATTACH DATABASE 'x'`, `VACUUM INTO 'x'` and bare `VACUUM` all surface as `AuthAction::Attach`, so one rule covers all three -- which keyword scanning would not, since `VACUUM INTO` contains no "ATTACH". Also deny vtable creation (file-backed modules such as csvfile can read and write arbitrary paths) and `Unknown`, so future SQLite statements fail closed. Tests cover both denied statements (asserting no file is left on disk, not merely that the call errors) and a real export round-trip, which guards against the allow-list regressing into false refusals. --- src-tauri/src/database/backup.rs | 128 ++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 776631b922..4808adf469 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -15,6 +15,56 @@ use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; +/// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory` +/// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。 +const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"]; + +/// 执行外部 SQL 期间的 authorizer:拒绝一切能**离开临时数据库文件**的动作。 +/// +/// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在 +/// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在 +/// `validate_basic_state` 之前,导入即使最终失败,文件也已经被创建;而 `settings` +/// 表不在 `SYNC_SKIP_TABLES` / `SYNC_PRESERVE_TABLES` 之列,WebDAV/S3 同步会走 +/// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。 +/// +/// 为什么是 authorizer 而不是「扫描 ATTACH 关键字」:字符串扫描会被 `/*x*/ATTACH`、 +/// 大小写、换行绕过,还漏掉 `VACUUM INTO`。authorizer 在 prepare 阶段按**解析结果** +/// 回调,绕不过语法层。 +/// +/// 为什么是「拒绝越界动作」而不是「只放行 dump_sql 的语句」:这段 SQL 跑在 +/// `NamedTempFile` 建的一次性库上,而那个库的全部内容本来就由这份 SQL 决定。 +/// 因此 `DELETE` / `DROP` / `UPDATE` 给不了攻击者任何新东西——**唯一有意义的边界 +/// 是那个临时文件本身**。按 dump_sql 的产物做严格白名单只会带来误伤风险(用户 +/// 库里出现一种没预料到的对象就恢复不了备份),却不多挡任何攻击。 +/// +/// 越界动作是实测出来的,不是推断的: +/// - `ATTACH DATABASE 'x'`、`VACUUM INTO 'x'`、裸 `VACUUM` **三者都**报 +/// `AuthAction::Attach`,所以拒 `Attach` 一条即可覆盖 +/// - 文件后端的虚拟表模块(`csvfile`、`zipfile` 等)能读写任意路径 → 拒 vtable +/// - `Unknown` 是 rusqlite 对未识别动作码的兜底 → 未知即拒,将来 SQLite 新增的 +/// 跨文件语句会默认落进这里,不依赖有人记得回来补名单 +fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization { + use rusqlite::hooks::{AuthAction, Authorization}; + + let escapes_temp_db = match context.action { + AuthAction::Attach { .. } | AuthAction::Detach { .. } => true, + AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true, + AuthAction::Unknown { .. } => true, + AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS + .iter() + .any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)), + _ => false, + }; + + if escapes_temp_db { + // SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。 + log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action); + Authorization::Deny + } else { + Authorization::Allow + } +} + /// Tables whose data rows are skipped when exporting for WebDAV sync. const SYNC_SKIP_TABLES: &[&str] = &[ "proxy_request_logs", @@ -117,9 +167,15 @@ impl Database { let temp_conn = Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; - temp_conn - .execute_batch(sql_content) - .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; + // authorizer 只覆盖外部 SQL,执行完立刻摘掉:紧随其后的 + // `create_tables_on_conn` / `apply_schema_migrations_on_conn` 是本程序自己的 + // schema 维护语句,不属于需要设防的输入,没必要让它们也过一遍守卫。 + temp_conn.authorizer(Some(import_authorizer)); + let batch_result = temp_conn.execute_batch(sql_content); + temp_conn.authorizer( + None::) -> rusqlite::hooks::Authorization>, + ); + batch_result.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; // 补齐缺失表/索引并进行基础校验 Self::create_tables_on_conn(&temp_conn)?; @@ -694,6 +750,72 @@ mod tests { use crate::settings::{update_settings, AppSettings}; use serial_test::serial; + #[test] + fn import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> { + // `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样, + // 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。 + let cases: [(&str, &str); 2] = [ + ("attach", "ATTACH DATABASE '{path}' AS evil;"), + ("vacuum-into", "VACUUM INTO '{path}';"), + ]; + + for (label, template) in cases { + let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite")); + let _ = std::fs::remove_file(&target); + + // 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它, + // 真正拦下来的必须是 authorizer。 + let malicious = format!( + "{}\n{}\n", + super::CC_SWITCH_SQL_EXPORT_HEADER, + template.replace("{path}", &target.display().to_string()) + ); + + let db = Database::memory()?; + let result = db.import_sql_string(&malicious); + + assert!(result.is_err(), "{label} 必须被拒绝"); + // 光报错不够:文件创建发生在 prepare 之后、`validate_basic_state` 之前, + // 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。 + assert!( + !target.exists(), + "被拒绝的 {label} 不得在磁盘上留下文件: {}", + target.display() + ); + + let _ = std::fs::remove_file(&target); + } + Ok(()) + } + + #[test] + fn import_still_accepts_a_genuine_export() -> Result<(), AppError> { + // 白名单收得紧,必须有一条回归防线证明它没误伤自家导出格式—— + // 这条测试红了就说明 dump_sql 写出了白名单没覆盖的语句。 + let source = Database::memory()?; + { + let conn = crate::database::lock_conn!(source.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('p1', 'claude', 'Provider One', '{}', '{}')", + [], + )?; + } + let exported = source.export_sql_string()?; + + let target = Database::memory()?; + target.import_sql_string(&exported)?; + + let conn = crate::database::lock_conn!(target.conn); + let name: String = conn.query_row( + "SELECT name FROM providers WHERE id = 'p1' AND app_type = 'claude'", + [], + |row| row.get(0), + )?; + assert_eq!(name, "Provider One"); + Ok(()) + } + #[test] fn sync_import_preserves_local_only_tables() -> Result<(), AppError> { let remote_db = Database::memory()?; From 35486afddac7c16a9977841f495f30fc09e83ae3 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Jul 2026 22:47:03 +0800 Subject: [PATCH 03/12] fix(sessions): use POSIX single-quote escaping for terminal cwd `shell_escape` wrapped the working directory in double quotes and escaped only `\` and `"`. Inside double quotes a shell still expands `$(...)`, backticks and `$VAR`, so the quoting stopped spaces but not command substitution. Verified: `cd "/tmp/$(id -un)"` runs `id`. The value is `selectedSession.projectDir` -- a real path recorded in the AI CLI's session history. macOS allows `$`, `(` and `)` in directory names, so any project whose folder is named that way triggers it on Resume; no compromised renderer is required. Three built-in launchers were affected because they route through `build_shell_command(command, cwd)`: Terminal.app, iTerm and kitty. Ghostty, WezTerm/Kaku and Alacritty were already correct -- they pass the directory as its own argv element (`--working-directory` / `--cwd`) and call `build_shell_command(command, None)`. Terminal and iTerm go through AppleScript `do script`, which accepts a single shell line and has no cwd parameter, so correct quoting is the only option there. Switch to POSIX single quotes, where nothing expands, using the close-escape-reopen `'\''` sequence for embedded quotes. A test pins the two-layer interaction with `escape_osascript`, which doubles backslashes on the way into the AppleScript literal. Also escape the `{cwd}` substitution in `launch_custom`, and correct that function's comment: the escaping there is context-dependent and only holds while the placeholder sits in an unquoted shell word. A template written as `echo "{cwd}"` puts the inserted quotes inside double quotes and command substitution runs again. The branch has no UI entry point today; the note now says it must be redesigned before one is added rather than implying it is already safe. --- src-tauri/src/session_manager/terminal/mod.rs | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index 420eff9ba2..2124e6c17a 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -286,11 +286,21 @@ fn launch_custom( } let cmd_str = command; - let dir_str = cwd.unwrap_or("."); + // `{cwd}` 是磁盘上扫来的路径,先做转义;`{command}` 保持原样——模板作者写下 + // 这个占位符的本意就是让它当命令展开。 + // + // ⚠️ 这里的转义**不是完备防护**,只在占位符处于未加引号的 shell 词位置时成立。 + // 模板若写成 `echo "{cwd}"`,插入的单引号会落进双引号里变成普通字符,`cwd` + // 里的 `$(...)` 照样求值。安全性取决于模板怎么写,而模板不由这里控制。 + // + // 目前本分支无 UI 入口(终端选项列表没有 `custom`,前端也从不传 + // `customConfig`),所以不可达。**接线前必须换掉这个方案**——正确做法是让 + // 模板声明参数位、由此处按 argv 传递,而不是让用户拼 shell 字符串。 + let dir_str = shell_escape(cwd.unwrap_or(".")); let final_cmd_line = template .replace("{command}", cmd_str) - .replace("{cwd}", dir_str); + .replace("{cwd}", &dir_str); // Execute via sh -c let status = Command::new("sh") @@ -315,9 +325,16 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String { } } +/// POSIX 单引号转义。 +/// +/// **必须是单引号**:双引号内 `$(...)`、反引号、`$VAR` 照常展开,而这里包的是 +/// `projectDir`——会话历史里记录的真实项目路径,macOS 允许目录名含 `$` `(` `)`, +/// 所以一个名为 `$(...)` 的目录就足以让命令替换在用户终端里执行。 +/// +/// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」 +/// 的 `'\''` 序列绕过。 fn shell_escape(value: &str) -> String { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") + format!("'{}'", value.replace('\'', r"'\''")) } fn escape_osascript(value: &str) -> String { @@ -377,6 +394,47 @@ mod tests { ); // Verify shell_escape works correctly for paths with spaces - assert_eq!(shell_escape(cwd), "\"/tmp/project dir\""); + assert_eq!(shell_escape(cwd), "'/tmp/project dir'"); + } + + #[test] + fn shell_escape_neutralizes_command_substitution_in_directory_names() { + // 这些字符在 macOS 目录名里全部合法,而 `cwd` 就是会话历史里的 + // `projectDir`——一个名为 `$(...)` 的目录必须原样落到 `cd` 后面, + // 不能被 shell 求值。旧的双引号实现对这三种全部失守。 + assert_eq!(shell_escape("/tmp/$(id -un)"), "'/tmp/$(id -un)'"); + assert_eq!(shell_escape("/tmp/`id -un`"), "'/tmp/`id -un`'"); + assert_eq!(shell_escape("/tmp/$HOME"), "'/tmp/$HOME'"); + } + + #[test] + fn shell_escape_handles_embedded_single_quote() { + // 单引号是单引号包裹法唯一表示不了的字符,靠「闭合-转义-重开」绕过。 + assert_eq!(shell_escape("/tmp/it's"), r"'/tmp/it'\''s'"); + } + + #[test] + fn shell_escape_survives_the_osascript_layer() { + // Terminal / iTerm 的链路是两层:shell_escape 的结果先被塞进 AppleScript + // 字符串字面量,由 escape_osascript 再转义一次,AppleScript 求值后才交给 + // shell。反斜杠会在中间那层被加倍,必须确认最终落到 shell 的字节没变形。 + let escaped = shell_escape("/tmp/it's"); + assert_eq!(escaped, r"'/tmp/it'\''s'"); + + let for_applescript = escape_osascript(&escaped); + assert_eq!(for_applescript, r"'/tmp/it'\\''s'"); + + // AppleScript 把 `\\` 求值回单个 `\`,于是 shell 拿到的正是 escaped 本身。 + assert_eq!(for_applescript.replace(r"\\", r"\"), escaped); + } + + #[test] + fn build_shell_command_quotes_the_cwd_it_prefixes() { + // Terminal / iTerm / kitty 三条路径都经这里;ghostty / wezterm / alacritty + // 走 `cwd = None` 并把目录当独立 argv 传,不受影响。 + assert_eq!( + build_shell_command("claude --resume x", Some("/tmp/$(id -un)")), + "cd '/tmp/$(id -un)' && claude --resume x" + ); } } From 134bdc0e656d2717af772c09989e7395aefa63d2 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Jul 2026 22:47:18 +0800 Subject: [PATCH 04/12] docs(sessions): record the renderer trust boundary for terminal launch `launch_session_terminal` takes an arbitrary string from the renderer and hands it to a shell. External audits report this as arbitrary command execution over IPC. Document it as a known, accepted risk instead of leaving it to be re-reported every audit cycle. The precondition for exploiting it is control over the renderer, which already implies local code execution as the user -- at which point going through this command grants nothing extra. The renderer is treated as a trusted boundary, supported by four facts each verified against the tree: - the only `dangerouslySetInnerHTML` (ProviderIcon) takes an icon *name*, gated by `hasIcon()`, and reads the SVG from a build-time registry; neither users nor deep links can supply markup - no `eval` / `new Function` anywhere in the frontend - `frontendDist` points at the bundled output, the webview loads no remote origin, and there are no `