diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 46c3e50..0a2539e 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -1901,7 +1901,12 @@ fn validate_ios_cocoapods_paths(server: &Arc, project: &Path) -> Result< 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 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 uses_pods = root.join("Podfile").is_file() || root.join("podfile").is_file() || root.join("Podfile.lock").is_file() diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 11956b4..2dc7ecf 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -920,6 +920,76 @@ fn checks_project_aliases_by_entry_name() { assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); } +#[cfg(unix)] +#[test] +fn rejects_internal_workspace_parent_pbx_symlink() { + use std::os::unix::fs::symlink; + + let allowed = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let host = allowed.path().join("Host.xcodeproj"); + let workspace = host.join("project.xcworkspace"); + fs::create_dir_all(&workspace).unwrap(); + let outside_pbx = outside.path().join("outside.pbxproj"); + fs::write( + &outside_pbx, + b"PODS_ROOT = 1;\n[CP] Check Pods Manifest.lock\n", + ) + .unwrap(); + let parent_pbx = host.join("project.pbxproj"); + symlink(&outside_pbx, &parent_pbx).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}"); + } + + fs::remove_file(&parent_pbx).unwrap(); + fs::write(&parent_pbx, b"// inside\n").unwrap(); + 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": allowed.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 rejects_cocoapods_lock_symlink_escape() {