diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 0a2539e..6e4a0b2 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -1434,6 +1434,7 @@ fn read_report(server: &Arc, args: Value) -> Result { if path.extension().and_then(|value| value.to_str()) != Some("json") { anyhow::bail!("保护报告必须是 JSON 文件"); } + ensure_regular_file(&path, "保护报告")?; let metadata = fs::metadata(&path)?; if metadata.len() > 64 * 1024 * 1024 { anyhow::bail!("保护报告超过 64 MiB"); @@ -1726,6 +1727,17 @@ fn absolute_entry_path(value: &str) -> Result { } } +fn ensure_regular_file(path: &Path, label: &str) -> Result<()> { + let metadata = + fs::metadata(path).with_context(|| format!("读取{label}元数据失败: {}", path.display()))?; + anyhow::ensure!( + metadata.is_file(), + "{label}不是普通文件: {}", + path.display() + ); + Ok(()) +} + const MAX_IOS_BUNDLE_ENTRIES: usize = 50_000; const MAX_IOS_BUNDLE_DEPTH: usize = 32; @@ -1748,6 +1760,32 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { continue; } visited.insert(directory.clone(), depth); + // 使用 CLI 的读取路径,让文件系统匹配大小写,同时识别必需文件的失效链接。 + for name in ["project.pbxproj", "contents.xcworkspacedata"] { + let path = directory.join(name); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).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 工程内部路径")?; + ensure_regular_file(&target, "iOS 工程必需文件")?; + } else { + anyhow::ensure!( + metadata.is_file(), + "iOS 工程必需文件不是普通文件: {}", + path.display() + ); + } + } let children = fs::read_dir(&directory) .with_context(|| format!("读取 iOS 工程目录失败: {}", directory.display()))?; for child in children { @@ -1759,29 +1797,18 @@ fn validate_ios_bundle(server: &Arc, root: &Path) -> Result<()> { root.display() ); let path = child.path(); - let metadata = fs::symlink_metadata(&path) + let file_type = child + .file_type() .with_context(|| format!("读取 iOS 工程路径失败: {}", path.display()))?; - if metadata.file_type().is_symlink() { - 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(_) => {} + if file_type.is_symlink() { + let Ok(target) = path.canonicalize() else { + continue; + }; + server.ensure_allowed(&target, "iOS 工程内部路径")?; + if target.is_dir() { + pending.push((target, depth + 1)); } - } else if metadata.is_dir() { + } else if file_type.is_dir() { pending.push((path, depth + 1)); } } @@ -1903,10 +1930,13 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< let project_dir = if internal_workspace { parent } else { project }; let pbx_path = project_dir.join("project.pbxproj"); // 读取前先校验实际路径;内部 workspace 的父工程 pbx 不在 workspace bundle 的扫描范围内。 - if pbx_path.exists() { - server.resolve_input(&pbx_path.to_string_lossy(), "iOS project.pbxproj")?; - } - let pbx = fs::read_to_string(&pbx_path).unwrap_or_default(); + let pbx = if pbx_path.exists() { + let pbx_path = server.resolve_input(&pbx_path.to_string_lossy(), "iOS project.pbxproj")?; + ensure_regular_file(&pbx_path, "iOS project.pbxproj")?; + fs::read_to_string(&pbx_path).unwrap_or_default() + } else { + String::new() + }; let uses_pods = root.join("Podfile").is_file() || root.join("podfile").is_file() || root.join("Podfile.lock").is_file() @@ -1916,18 +1946,26 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< 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"), + for (path, label, regular_required) in [ + (root.join("Podfile"), "CocoaPods Podfile", false), + (root.join("podfile"), "CocoaPods podfile", false), + (root.join("Podfile.lock"), "CocoaPods Podfile.lock", true), + ( + root.join("Pods/Manifest.lock"), + "CocoaPods Manifest.lock", + true, + ), ( root.join("Pods/Pods.xcodeproj/project.pbxproj"), "CocoaPods Pods 工程", + false, ), ] { if path.exists() { - server.resolve_input(&path.to_string_lossy(), label)?; + let resolved = server.resolve_input(&path.to_string_lossy(), label)?; + if regular_required { + ensure_regular_file(&resolved, label)?; + } } } Ok(()) @@ -1935,6 +1973,7 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< /// iOS 配置中的工程、entrypoint 和 Swift Confidential 路径也必须受允许目录约束。 fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) -> Result<()> { + ensure_regular_file(config, "iOS 配置")?; let content = fs::read_to_string(config) .with_context(|| format!("读取 iOS 配置失败:{}", config.display()))?; let value: toml::Value = toml::from_str(&content).context("解析 iOS 配置失败")?; diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 2dc7ecf..8f9f3de 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -990,6 +990,286 @@ fn rejects_internal_workspace_parent_pbx_symlink() { assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); } +#[cfg(unix)] +#[test] +fn validates_uppercase_required_pbx_on_case_insensitive_fs() { + use std::os::unix::fs::symlink; + + let sample = tempfile::tempdir().unwrap(); + fs::write(sample.path().join("CaseProbe"), b"x").unwrap(); + if !sample.path().join("CASEPROBE").exists() { + return; + } + + let workspace = sample.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let child = sample.path().join("Child.xcodeproj"); + fs::create_dir_all(&child).unwrap(); + let pbx = child.join("PROJECT.PBXPROJ"); + assert!(Command::new("mkfifo").arg(&pbx).status().unwrap().success()); + 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 started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "大写 FIFO pbx 阻塞了检查" + ); + 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(&pbx).unwrap(); + symlink(sample.path().join("missing.pbxproj"), &pbx).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(&pbx).unwrap(); + fs::write(&pbx, b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.case;").unwrap(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], false, "{check}"); +} + +#[cfg(unix)] +#[test] +fn rejects_non_regular_cocoapods_locks_without_blocking() { + 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 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(); + fs::write(sample.path().join("Podfile"), b"platform :ios\n").unwrap(); + let podfile_lock = sample.path().join("Podfile.lock"); + let manifest_lock = sample.path().join("Pods/Manifest.lock"); + fs::write(&manifest_lock, b"PODS:\n").unwrap(); + assert!(Command::new("mkfifo") + .arg(&podfile_lock) + .status() + .unwrap() + .success()); + 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 started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + let check_elapsed = started.elapsed(); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + check_elapsed < Duration::from_secs(5), + "FIFO 锁文件阻塞了检查: {check_elapsed:?}" + ); + + let started = Instant::now(); + 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}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "FIFO 锁文件阻塞了保护预检" + ); + + fs::remove_file(&podfile_lock).unwrap(); + fs::write(&podfile_lock, b"PODS:\n").unwrap(); + fs::remove_file(&manifest_lock).unwrap(); + assert!(Command::new("mkfifo") + .arg(&manifest_lock) + .status() + .unwrap() + .success()); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); +} + +#[cfg(unix)] +#[test] +fn rejects_non_regular_child_project_pbx_without_blocking() { + let sample = tempfile::tempdir().unwrap(); + let workspace = sample.path().join("Audit.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let child = sample.path().join("Child.xcodeproj"); + fs::create_dir_all(&child).unwrap(); + assert!(Command::new("mkfifo") + .arg(child.join("project.pbxproj")) + .status() + .unwrap() + .success()); + 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 started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "FIFO 子工程 pbx 阻塞了检查" + ); + 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}"); +} + +#[cfg(unix)] +#[test] +fn rejects_non_regular_parent_pbx_without_blocking() { + let allowed = tempfile::tempdir().unwrap(); + let host = allowed.path().join("Host.xcodeproj"); + let workspace = host.join("project.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let fifo = host.join("project.pbxproj"); + let status = Command::new("mkfifo").arg(&fifo).status().unwrap(); + assert!(status.success(), "创建 FIFO 失败"); + 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 started = Instant::now(); + let check = client.call( + "shellsmith_check_ios", + json!({"project": workspace.display().to_string()}), + ); + let check_elapsed = started.elapsed(); + assert_eq!(check["isError"], true, "{check}"); + assert!(text(&check).contains("不是普通文件"), "{check}"); + assert!( + check_elapsed < Duration::from_secs(5), + "非普通文件阻塞了检查: {check_elapsed:?}" + ); + + let started = Instant::now(); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": allowed.path().join("out").display().to_string(), + "dry_run": true + }), + ); + let protect_elapsed = started.elapsed(); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); + assert!( + protect_elapsed < Duration::from_secs(5), + "非普通文件阻塞了保护预检: {protect_elapsed:?}" + ); +} + +#[cfg(unix)] +#[test] +fn rejects_non_regular_ios_config_and_report() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let config_fifo = temp.path().join("ios.toml"); + let report_fifo = temp.path().join("report.json"); + for fifo in [&config_fifo, &report_fifo] { + let status = Command::new("mkfifo").arg(fifo).status().unwrap(); + assert!(status.success(), "创建 FIFO 失败"); + } + + let mut client = McpClient::start(&cli, &[temp.path()], &[]); + let protect = client.call( + "shellsmith_protect_ios", + json!({ + "ios_config": config_fifo.display().to_string(), + "output": temp.path().join("out").display().to_string(), + "dry_run": true + }), + ); + assert_eq!(protect["isError"], true, "{protect}"); + assert!(text(&protect).contains("不是普通文件"), "{protect}"); + + let report = client.call( + "shellsmith_read_report", + json!({"path": report_fifo.display().to_string()}), + ); + assert_eq!(report["isError"], true, "{report}"); + assert!(text(&report).contains("不是普通文件"), "{report}"); +} + #[cfg(unix)] #[test] fn rejects_cocoapods_lock_symlink_escape() { diff --git a/docs/mcp.md b/docs/mcp.md index 9fbd921..bf72c50 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 本身会被明确拒绝。CocoaPods 的 `Podfile.lock`、`Pods/Manifest.lock` 等实际读取文件也会校验;bundle 内与 CLI 实际读取无关的失效链接会被忽略。 +- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。CocoaPods 的 `Podfile.lock`、`Pods/Manifest.lock` 以及用于判断依赖的 `project.pbxproj` 会在读取前校验路径和普通文件类型;bundle 内与 CLI 实际读取无关的失效链接会被忽略。 - **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。 - **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。 - **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。