From d69acb64ebf2748cc52064647d241440a54d3de1 Mon Sep 17 00:00:00 2001 From: haonan Date: Fri, 13 Nov 2026 12:08:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=20iOS=20=E4=B8=BB?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=88=AB=E5=90=8D=E7=B1=BB=E5=9E=8B=E5=B9=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20CocoaPods=20=E9=94=81=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工程类型按原始入口扩展名识别,不再因规范化目标无扩展名而跳过预检 - 直接检查使用原始入口路径调用 CLI,主入口别名可正常工作 - CocoaPods Podfile/Podfile.lock/Pods/Manifest.lock 等实际读取路径纳入允许目录校验 - 保留 Podfile 缺失时的缓存目录跳过语义,不递归扫描 Pods - 增加主入口别名与 CocoaPods 锁文件外链回归测试 --- apps/shellsmith-mcp/src/tools.rs | 79 ++++++++++++++--- apps/shellsmith-mcp/tests/mcp_stdio.rs | 115 +++++++++++++++++++++++-- docs/mcp.md | 2 +- 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 624e369..46c3e50 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -608,9 +608,11 @@ fn dispatch( "shellsmith_check_aab" => check_aab(server, args), "shellsmith_check_ios" => { let input: CheckIosInput = parse(args)?; + let entry = absolute_entry_path(&input.project)?; + let entry_kind = entry.extension().and_then(|value| value.to_str()); let project = server.resolve_input(&input.project, "Xcode 工程")?; - validate_ios_project(server, &project, true)?; - let mut cli_args = vec!["check-ios".to_string(), project.display().to_string()]; + validate_ios_project(server, &project, entry_kind, true)?; + let mut cli_args = vec!["check-ios".to_string(), entry.display().to_string()]; if let Some(scheme) = input.scheme { cli_args.push("--scheme".into()); cli_args.push(scheme); @@ -1715,6 +1717,15 @@ fn resolve_config_path(base: &Path, value: &str) -> PathBuf { } } +fn absolute_entry_path(value: &str) -> Result { + let path = PathBuf::from(value); + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + const MAX_IOS_BUNDLE_ENTRIES: usize = 50_000; const MAX_IOS_BUNDLE_DEPTH: usize = 32; @@ -1847,14 +1858,16 @@ fn ensure_ios_source_root_allowed(server: &Arc, project: &Path) -> Resul fn validate_ios_project( server: &Arc, project: &Path, + entry_kind: Option<&str>, require_source_root: bool, ) -> Result<()> { - match project.extension().and_then(|value| value.to_str()) { + match entry_kind { Some("xcodeproj") => { if require_source_root { ensure_ios_source_root_allowed(server, project)?; } - validate_ios_bundle(server, project) + validate_ios_bundle(server, project)?; + validate_ios_cocoapods_paths(server, project) } Some("xcworkspace") => { ensure_ios_source_root_allowed(server, project)?; @@ -1866,12 +1879,55 @@ fn validate_ios_project( validate_ios_bundle(server, &project)?; } } - Ok(()) + validate_ios_cocoapods_paths(server, project) } - _ => Ok(()), + _ => anyhow::bail!( + "iOS 输入必须是 .xcodeproj 或 .xcworkspace: {}", + project.display() + ), } } +/// CocoaPods 预检会直接读取源码根目录的锁文件;这些路径也必须受允许目录约束。 +fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result<()> { + let Some(parent) = project.parent() else { + return Ok(()); + }; + let internal_workspace = + parent.extension().and_then(|value| value.to_str()) == Some("xcodeproj"); + let root = if internal_workspace { + parent.parent().unwrap_or(parent) + } else { + parent + }; + let project_dir = if internal_workspace { parent } else { project }; + let pbx = fs::read_to_string(project_dir.join("project.pbxproj")).unwrap_or_default(); + let uses_pods = root.join("Podfile").is_file() + || root.join("podfile").is_file() + || root.join("Podfile.lock").is_file() + || pbx.contains("PODS_ROOT") + || pbx.contains("[CP] Check Pods Manifest.lock"); + if !uses_pods { + return Ok(()); + } + + for (path, label) in [ + (root.join("Podfile"), "CocoaPods Podfile"), + (root.join("podfile"), "CocoaPods podfile"), + (root.join("Podfile.lock"), "CocoaPods Podfile.lock"), + (root.join("Pods/Manifest.lock"), "CocoaPods Manifest.lock"), + ( + root.join("Pods/Pods.xcodeproj/project.pbxproj"), + "CocoaPods Pods 工程", + ), + ] { + if path.exists() { + server.resolve_input(&path.to_string_lossy(), label)?; + } + } + Ok(()) +} + /// iOS 配置中的工程、entrypoint 和 Swift Confidential 路径也必须受允许目录约束。 fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) -> Result<()> { let content = fs::read_to_string(config) @@ -1884,11 +1940,10 @@ fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) .and_then(|project| project.get("path")) .and_then(toml::Value::as_str) .context("iOS 配置缺少 project.path")?; - let project_path = server.resolve_input( - &resolve_config_path(base, project).to_string_lossy(), - "iOS 工程", - )?; - validate_ios_project(server, &project_path, true)?; + let project_entry = resolve_config_path(base, project); + let entry_kind = project_entry.extension().and_then(|value| value.to_str()); + let project_path = server.resolve_input(&project_entry.to_string_lossy(), "iOS 工程")?; + validate_ios_project(server, &project_path, entry_kind, true)?; let profile = value .get("protection") @@ -1903,7 +1958,7 @@ fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) .and_then(|project| project.get("entrypoint")) .and_then(toml::Value::as_str) { - let project_root = project_path.parent().unwrap_or(base); + let project_root = project_entry.parent().unwrap_or(base); server.resolve_input( &resolve_config_path(project_root, entrypoint).to_string_lossy(), "iOS entrypoint", diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index c6f6e90..11956b4 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -864,8 +864,82 @@ fn checks_project_aliases_by_entry_name() { let private = outside.path().join("private.pbxproj"); fs::write(&private, b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.alias;").unwrap(); symlink(&private, target.join("project.pbxproj")).unwrap(); - symlink(&target, allowed.path().join("Alias.xcodeproj")).unwrap(); + let alias = allowed.path().join("Alias.xcodeproj"); + symlink(&target, &alias).unwrap(); let cli = write_fake_cli(allowed.path()); + let alias_config = allowed.path().join("ios-alias.toml"); + fs::write( + &alias_config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + alias.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + let check = client.call( + "shellsmith_check_ios", + json!({"project": alias.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不在允许的目录内"), "{check}"); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": alias_config.display().to_string(), + "output": allowed.path().join("alias-out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不在允许的目录内"), "{protect}"); + + fs::remove_file(target.join("project.pbxproj")).unwrap(); + fs::write( + target.join("project.pbxproj"), + b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.inside;", + ) + .unwrap(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": alias.display().to_string()}), + ); + assert_eq!(check["isError"], false, "{check}"); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": alias_config.display().to_string(), + "output": allowed.path().join("alias-out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], false, "{protect}"); + let job_id = protect["structuredContent"]["job_id"].as_str().unwrap(); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); +} + +#[cfg(unix)] +#[test] +fn rejects_cocoapods_lock_symlink_escape() { + use std::os::unix::fs::symlink; + + let sample = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let workspace = sample.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let project = sample.path().join("Inside.xcodeproj"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("project.pbxproj"), b"// inside").unwrap(); + let pods_project = sample.path().join("Pods/Pods.xcodeproj"); + fs::create_dir_all(&pods_project).unwrap(); + fs::write(pods_project.join("project.pbxproj"), b"// pods").unwrap(); + let outside_lock = outside.path().join("external.lock"); + fs::write(&outside_lock, b"PODS:\n - Audit\n").unwrap(); + let podfile_lock = sample.path().join("Podfile.lock"); + let manifest_lock = sample.path().join("Pods/Manifest.lock"); + let cli = write_fake_cli(sample.path()); let config = workspace.join("ios.toml"); fs::write( &config, @@ -876,13 +950,44 @@ fn checks_project_aliases_by_entry_name() { ) .unwrap(); - let mut client = McpClient::start(&cli, &[allowed.path()], &[]); - let result = client.call( + let mut client = McpClient::start(&cli, &[sample.path()], &[]); + symlink(&outside_lock, &podfile_lock).unwrap(); + fs::write(&manifest_lock, b"PODS:\n - Audit\n").unwrap(); + let check = client.call( "shellsmith_check_ios", json!({"project": workspace.display().to_string()}), ); - assert_eq!(result["isError"], true, "{result}"); - assert!(text(&result).contains("不在允许的目录内"), "{result}"); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不在允许的目录内"), "{check}"); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": sample.path().join("out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不在允许的目录内"), "{protect}"); + + fs::remove_file(&podfile_lock).unwrap(); + fs::write(&podfile_lock, b"PODS:\n - Audit\n").unwrap(); + fs::remove_file(&manifest_lock).unwrap(); + symlink(&outside_lock, &manifest_lock).unwrap(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不在允许的目录内"), "{check}"); + + fs::remove_file(&manifest_lock).unwrap(); + fs::write(&manifest_lock, b"PODS:\n - Audit\n").unwrap(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], false, "{check}"); } #[cfg(unix)] diff --git a/docs/mcp.md b/docs/mcp.md index 4405e4e..9fbd921 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -214,7 +214,7 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" ## 安全边界 - **密码**:只从 MCP 进程环境读取;工具 schema 中没有 `password`、`ks_pass`、`key_password` 字段。错误信息不会回显密码。 -- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。bundle 内与 CLI 实际读取无关的失效链接会被忽略。 +- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。CocoaPods 的 `Podfile.lock`、`Pods/Manifest.lock` 等实际读取文件也会校验;bundle 内与 CLI 实际读取无关的失效链接会被忽略。 - **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。 - **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。 - **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。