diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 233e7c1..e8897ba 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -2,6 +2,7 @@ use crate::jobs::{CommandPlan, JobPlan, Server}; use anyhow::{Context, Result}; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -608,7 +609,7 @@ fn dispatch( "shellsmith_check_ios" => { let input: CheckIosInput = parse(args)?; let project = server.resolve_input(&input.project, "Xcode 工程")?; - validate_ios_project(server, &project)?; + validate_ios_project(server, &project, true)?; let mut cli_args = vec!["check-ios".to_string(), project.display().to_string()]; if let Some(scheme) = input.scheme { cli_args.push("--scheme".into()); @@ -1720,6 +1721,7 @@ const MAX_IOS_BUNDLE_DEPTH: usize = 32; /// 校验 iOS 工程 bundle 内实际会被读取的文件路径,重点拦截工程内部的文件级符号链接。 fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { let mut pending = vec![(root.to_path_buf(), 0usize)]; + let mut visited = HashSet::new(); let mut entries = 0usize; while let Some((directory, depth)) = pending.pop() { anyhow::ensure!( @@ -1727,6 +1729,10 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { "iOS 工程目录层级过深,无法完成路径校验: {}", directory.display() ); + let directory = directory.canonicalize().unwrap_or(directory); + if !visited.insert(directory.clone()) { + continue; + } let children = fs::read_dir(&directory) .with_context(|| format!("读取 iOS 工程目录失败: {}", directory.display()))?; for child in children { @@ -1741,10 +1747,25 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { let metadata = fs::symlink_metadata(&path) .with_context(|| format!("读取 iOS 工程路径失败: {}", path.display()))?; if metadata.file_type().is_symlink() { - let target = path - .canonicalize() - .with_context(|| format!("iOS 工程内部符号链接无法解析: {}", path.display()))?; - server.ensure_allowed(&target, "iOS 工程内部路径")?; + let required = matches!( + path.file_name().and_then(|value| value.to_str()), + Some("project.pbxproj" | "contents.xcworkspacedata") + ); + match path.canonicalize() { + Ok(target) => { + server.ensure_allowed(&target, "iOS 工程内部路径")?; + if target.is_dir() { + pending.push((target, depth + 1)); + } + } + Err(error) if required => { + return Err(anyhow::Error::new(error).context(format!( + "iOS 工程必需文件符号链接无法解析: {}", + path.display() + ))); + } + Err(_) => {} + } } else if metadata.is_dir() { pending.push((path, depth + 1)); } @@ -1758,29 +1779,49 @@ fn collect_ios_projects( server: &Arc, root: &Path, depth: usize, + visited: &mut HashSet, output: &mut Vec, ) -> Result<()> { if depth > 2 { return Ok(()); } + let root_key = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + if !visited.insert(root_key) { + return Ok(()); + } let entries = fs::read_dir(root) .with_context(|| format!("读取 iOS workspace 目录失败: {}", root.display()))?; for entry in entries { let entry = entry?; let path = entry.path(); + let is_project = path.extension().and_then(|value| value.to_str()) == Some("xcodeproj"); let metadata = fs::symlink_metadata(&path) .with_context(|| format!("读取 iOS workspace 路径失败: {}", path.display()))?; if metadata.file_type().is_symlink() { - let target = path - .canonicalize() - .with_context(|| format!("iOS workspace 符号链接无法解析: {}", path.display()))?; - server.ensure_allowed(&target, "iOS 工程内部路径")?; - if target.extension().and_then(|value| value.to_str()) == Some("xcodeproj") { + if is_project { + let target = path.canonicalize().with_context(|| { + format!("iOS workspace 工程符号链接无法解析: {}", path.display()) + })?; + anyhow::ensure!( + target.is_dir(), + "iOS workspace 工程符号链接目标不是目录: {}", + path.display() + ); + server.ensure_allowed(&target, "iOS 工程内部路径")?; output.push(target); + continue; + } + // CLI 只对 path.is_dir() 的链接继续遍历;失效或普通文件链接不会被读取。 + let Ok(target) = path.canonicalize() else { + continue; + }; + if target.is_dir() { + server.ensure_allowed(&target, "iOS 工程内部路径")?; + collect_ios_projects(server, &target, depth + 1, visited, output)?; } continue; } - if path.extension().and_then(|value| value.to_str()) == Some("xcodeproj") { + if is_project { output.push(path); } else if metadata.is_dir() && !matches!( @@ -1788,20 +1829,38 @@ fn collect_ios_projects( Some(".git" | "Pods" | ".build" | "DerivedData") ) { - collect_ios_projects(server, &path, depth + 1, output)?; + collect_ios_projects(server, &path, depth + 1, visited, output)?; } } Ok(()) } -fn validate_ios_project(server: &Arc, project: &Path) -> Result<()> { +fn ensure_ios_source_root_allowed(server: &Arc, project: &Path) -> Result<()> { + if let Some(root) = project.parent() { + server.ensure_allowed(root, "iOS 源码根目录")?; + } + Ok(()) +} + +fn validate_ios_project( + server: &Arc, + project: &Path, + require_source_root: bool, +) -> Result<()> { match project.extension().and_then(|value| value.to_str()) { - Some("xcodeproj") => validate_ios_bundle(server, project), + Some("xcodeproj") => { + if require_source_root { + ensure_ios_source_root_allowed(server, project)?; + } + validate_ios_bundle(server, project) + } Some("xcworkspace") => { + ensure_ios_source_root_allowed(server, project)?; validate_ios_bundle(server, project)?; if let Some(root) = project.parent() { let mut projects = Vec::new(); - collect_ios_projects(server, root, 0, &mut projects)?; + let mut visited = HashSet::new(); + collect_ios_projects(server, root, 0, &mut visited, &mut projects)?; for project in projects { validate_ios_bundle(server, &project)?; } @@ -1828,7 +1887,7 @@ fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) &resolve_config_path(base, project).to_string_lossy(), "iOS 工程", )?; - validate_ios_project(server, &project_path)?; + validate_ios_project(server, &project_path, true)?; let profile = value .get("protection") diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 311d9c2..68386b6 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -660,6 +660,228 @@ fn rejects_ios_project_internal_symlink_escape() { } } +#[test] +fn rejects_workspace_scanning_outside_allowed_root() { + let sample = tempfile::tempdir().unwrap(); + let workspace = sample.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let outside_project = sample.path().join("Outside.xcodeproj"); + fs::create_dir_all(&outside_project).unwrap(); + fs::write( + outside_project.join("project.pbxproj"), + b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.sibling.outside;", + ) + .unwrap(); + let cli = write_fake_cli(sample.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + // 只允许 workspace bundle,不允许其源码根目录。 + let mut client = McpClient::start(&cli, &[workspace.as_path()], &[]); + for (tool, arguments) in [ + ( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ), + ( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": workspace.join("out").display().to_string(), + "dry_run": true + }), + ), + ] { + let result = client.call(tool, arguments); + assert_eq!(result["isError"], true, "{result}"); + assert!(text(&result).contains("iOS 源码根目录"), "{result}"); + } +} + +#[test] +fn allows_workspace_when_source_root_is_allowed() { + let sample = 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 cli = write_fake_cli(sample.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[sample.path()], &[]); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], false, "{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"], 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 checks_projects_reached_through_directory_symlinks() { + use std::os::unix::fs::symlink; + + let allowed = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let holder = allowed.path().join("holder"); + let workspace = holder.join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let actual = allowed.path().join("actual"); + let nested = actual.join("Nested.xcodeproj"); + fs::create_dir_all(&nested).unwrap(); + let private = outside.path().join("private.pbxproj"); + fs::write(&private, b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.skipped;").unwrap(); + symlink(&private, nested.join("project.pbxproj")).unwrap(); + symlink(&actual, holder.join("LinkedSources")).unwrap(); + let cli = write_fake_cli(allowed.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + for (tool, arguments) in [ + ( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ), + ( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": allowed.path().join("out").display().to_string(), + "dry_run": true + }), + ), + ] { + let result = client.call(tool, arguments); + assert_eq!(result["isError"], true, "{result}"); + assert!(text(&result).contains("不在允许的目录内"), "{result}"); + } +} + +#[cfg(unix)] +#[test] +fn checks_project_aliases_by_entry_name() { + use std::os::unix::fs::symlink; + + let allowed = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let workspace = allowed.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let target = allowed.path().join("bundle-no-extension"); + fs::create_dir_all(&target).unwrap(); + 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 cli = write_fake_cli(allowed.path()); + let config = workspace.join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + workspace.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + let result = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(result["isError"], true, "{result}"); + assert!(text(&result).contains("不在允许的目录内"), "{result}"); +} + +#[cfg(unix)] +#[test] +fn ignores_unrelated_broken_workspace_symlinks() { + use std::os::unix::fs::symlink; + + let sample = 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(); + symlink( + sample.path().join("missing.txt"), + sample.path().join("unused.txt"), + ) + .unwrap(); + symlink(workspace.join("missing.txt"), workspace.join("unused.txt")).unwrap(); + let cli = write_fake_cli(sample.path()); + + let mut client = McpClient::start(&cli, &[sample.path()], &[]); + let result = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(result["isError"], false, "{result}"); +} + +#[cfg(unix)] +#[test] +fn rejects_ios_bundle_directory_symlink_escape() { + use std::os::unix::fs::symlink; + + let allowed = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let project = allowed.path().join("App.xcodeproj"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("project.pbxproj"), b"// inside").unwrap(); + let shared = allowed.path().join("shared"); + fs::create_dir_all(&shared).unwrap(); + let outside_schemes = outside.path().join("xcschemes"); + fs::create_dir_all(&outside_schemes).unwrap(); + symlink(&outside_schemes, shared.join("xcschemes")).unwrap(); + symlink(&shared, project.join("xcshareddata")).unwrap(); + let cli = write_fake_cli(allowed.path()); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + let result = client.call( + "shellsmith_check_ios", + json!({"project": project.display().to_string()}), + ); + assert_eq!(result["isError"], true, "{result}"); + assert!(text(&result).contains("不在允许的目录内"), "{result}"); +} + #[test] fn compat_skips_unused_confidential_and_entrypoint_paths() { let allowed = tempfile::tempdir().unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 98a1c5e..4405e4e 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 内部的符号链接都会单独校验,不因配置文件或工程目录本身在允许目录内而放行外部文件。 +- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。bundle 内与 CLI 实际读取无关的失效链接会被忽略。 - **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。 - **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。 - **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。