diff --git a/apps/shellsmith-mcp/src/jobs.rs b/apps/shellsmith-mcp/src/jobs.rs index 608a740..e06e3ea 100644 --- a/apps/shellsmith-mcp/src/jobs.rs +++ b/apps/shellsmith-mcp/src/jobs.rs @@ -895,6 +895,10 @@ fn notify_progress(server: &Server, job: &Arc>) { fn parse_json_output(bytes: &[u8]) -> Result { let text = String::from_utf8_lossy(bytes); + let trimmed = text.trim(); + if let Ok(value) = serde_json::from_str::(trimmed) { + return Ok(value); + } for line in text.lines().rev() { let line = line.trim(); if line.is_empty() { @@ -904,7 +908,7 @@ fn parse_json_output(bytes: &[u8]) -> Result { return Ok(value); } } - Ok(Value::String(text.trim().to_string())) + Ok(Value::String(trimmed.to_string())) } fn absolute_path(value: &str) -> Result { @@ -921,3 +925,35 @@ pub fn now_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use super::parse_json_output; + use serde_json::Value; + + #[test] + fn parses_complete_pretty_json_before_line_fallback() { + let text = br#"{ + "project_path": "/tmp/App.xcodeproj", + "schemes": ["Audit", "Other"], + "checks": [] +}"#; + let value = parse_json_output(text).unwrap(); + assert_eq!(value["project_path"], "/tmp/App.xcodeproj"); + assert_eq!(value["schemes"][0], "Audit"); + assert!(value["checks"].as_array().unwrap().is_empty()); + } + + #[test] + fn falls_back_to_last_json_event_line() { + let text = b"{\"type\":\"progress\",\"message\":\"working\"}\n{\"type\":\"done\"}\n"; + let value = parse_json_output(text).unwrap(); + assert_eq!(value["type"], "done"); + } + + #[test] + fn keeps_plain_text_when_no_json_is_present() { + let value = parse_json_output(b"shield 1.10.1\n").unwrap(); + assert_eq!(value, Value::String("shield 1.10.1".to_string())); + } +} diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index 5fba429..233e7c1 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -608,6 +608,7 @@ fn dispatch( "shellsmith_check_ios" => { let input: CheckIosInput = parse(args)?; let project = server.resolve_input(&input.project, "Xcode 工程")?; + validate_ios_project(server, &project)?; let mut cli_args = vec!["check-ios".to_string(), project.display().to_string()]; if let Some(scheme) = input.scheme { cli_args.push("--scheme".into()); @@ -1266,7 +1267,7 @@ fn protect_ios( require_confirm(input.confirm)?; } let config = server.resolve_input(&input.ios_config, "iOS 配置")?; - validate_ios_config_paths(server, &config)?; + validate_ios_config_paths(server, &config, input.dry_run)?; let output = server.resolve_output(&input.output, "iOS 输出目录")?; let mut cli_args = vec![ "protect-ios".to_string(), @@ -1713,17 +1714,106 @@ fn resolve_config_path(base: &Path, value: &str) -> PathBuf { } } -fn ensure_config_path_allowed(server: &Arc, path: &Path, label: &str) -> Result<()> { - if path.exists() { - server.resolve_input(&path.to_string_lossy(), label)?; - } else { - server.resolve_output(&path.to_string_lossy(), label)?; +const MAX_IOS_BUNDLE_ENTRIES: usize = 50_000; +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 entries = 0usize; + while let Some((directory, depth)) = pending.pop() { + anyhow::ensure!( + depth <= MAX_IOS_BUNDLE_DEPTH, + "iOS 工程目录层级过深,无法完成路径校验: {}", + directory.display() + ); + let children = fs::read_dir(&directory) + .with_context(|| format!("读取 iOS 工程目录失败: {}", directory.display()))?; + for child in children { + let child = child?; + entries += 1; + anyhow::ensure!( + entries <= MAX_IOS_BUNDLE_ENTRIES, + "iOS 工程目录条目过多,无法完成路径校验: {}", + root.display() + ); + let path = child.path(); + 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 工程内部路径")?; + } else if metadata.is_dir() { + pending.push((path, depth + 1)); + } + } + } + Ok(()) +} + +/// workspace 检查会扫描同级的 .xcodeproj;保持与 CLI 相同的深度/跳过规则。 +fn collect_ios_projects( + server: &Arc, + root: &Path, + depth: usize, + output: &mut Vec, +) -> Result<()> { + if depth > 2 { + 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 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") { + output.push(target); + } + continue; + } + if path.extension().and_then(|value| value.to_str()) == Some("xcodeproj") { + output.push(path); + } else if metadata.is_dir() + && !matches!( + entry.file_name().to_str(), + Some(".git" | "Pods" | ".build" | "DerivedData") + ) + { + collect_ios_projects(server, &path, depth + 1, output)?; + } } Ok(()) } +fn validate_ios_project(server: &Arc, project: &Path) -> Result<()> { + match project.extension().and_then(|value| value.to_str()) { + Some("xcodeproj") => validate_ios_bundle(server, project), + Some("xcworkspace") => { + validate_ios_bundle(server, project)?; + if let Some(root) = project.parent() { + let mut projects = Vec::new(); + collect_ios_projects(server, root, 0, &mut projects)?; + for project in projects { + validate_ios_bundle(server, &project)?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + /// iOS 配置中的工程、entrypoint 和 Swift Confidential 路径也必须受允许目录约束。 -fn validate_ios_config_paths(server: &Arc, config: &Path) -> Result<()> { +fn validate_ios_config_paths(server: &Arc, config: &Path, dry_run: bool) -> Result<()> { let content = fs::read_to_string(config) .with_context(|| format!("读取 iOS 配置失败:{}", config.display()))?; let value: toml::Value = toml::from_str(&content).context("解析 iOS 配置失败")?; @@ -1734,32 +1824,46 @@ fn validate_ios_config_paths(server: &Arc, config: &Path) -> Result<()> .and_then(|project| project.get("path")) .and_then(toml::Value::as_str) .context("iOS 配置缺少 project.path")?; - let project_path = resolve_config_path(base, project); - server.resolve_input(&project_path.to_string_lossy(), "iOS 工程")?; + let project_path = server.resolve_input( + &resolve_config_path(base, project).to_string_lossy(), + "iOS 工程", + )?; + validate_ios_project(server, &project_path)?; - if let Some(entrypoint) = value - .get("project") - .and_then(|project| project.get("entrypoint")) + let profile = value + .get("protection") + .and_then(|protection| protection.get("profile")) .and_then(toml::Value::as_str) - { - let project_root = project_path.parent().unwrap_or(base); - ensure_config_path_allowed( - server, - &resolve_config_path(project_root, entrypoint), - "iOS entrypoint", - )?; + .unwrap_or("balanced"); + let uses_protection = profile != "compat"; + + if uses_protection && !dry_run { + if let Some(entrypoint) = value + .get("project") + .and_then(|project| project.get("entrypoint")) + .and_then(toml::Value::as_str) + { + let project_root = project_path.parent().unwrap_or(base); + server.resolve_input( + &resolve_config_path(project_root, entrypoint).to_string_lossy(), + "iOS entrypoint", + )?; + } } - if let Some(confidential) = value.get("confidential") { - let enabled = confidential - .get("enabled") - .and_then(toml::Value::as_bool) - .unwrap_or(true); - if enabled { - if let Some(config_path) = confidential.get("config").and_then(toml::Value::as_str) { - ensure_config_path_allowed( - server, - &resolve_config_path(base, config_path), + if uses_protection { + if let Some(confidential) = value.get("confidential") { + let enabled = confidential + .get("enabled") + .and_then(toml::Value::as_bool) + .unwrap_or(true); + if enabled { + let config_path = confidential + .get("config") + .and_then(toml::Value::as_str) + .context("Swift Confidential 已启用但缺少 config")?; + server.resolve_input( + &resolve_config_path(base, config_path).to_string_lossy(), "Swift Confidential 配置", )?; } diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index 4487200..311d9c2 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -243,6 +243,9 @@ case "$cmd" in sleep "${FAKE_CHECK_SLEEP:-0}" echo '{"has_base":true,"modules":[]}' ;; + check-ios) + printf '{\n "project_path": "/tmp/App.xcodeproj",\n "checks": [],\n "schemes": [\n "Audit"\n ]\n}\n' + ;; check-keystore) if [ "$MOCIKA_SHIELD_KS_PASS" != "test-secret" ]; then echo '{"type":"error","message":"bad keystore password"}' @@ -526,13 +529,18 @@ fn rejects_ios_config_auxiliary_paths_outside_allowed_root() { .unwrap(); let mut client = McpClient::start(&cli, &[allowed.path()], &[]); - for config in [entrypoint_config, confidential_config] { + let cases = [ + (entrypoint_config, false, true), + (confidential_config, true, false), + ]; + for (config, dry_run, confirm) in cases { let result = client.call( "shellsmith_protect_ios", json!({ "ios_config": config.display().to_string(), "output": allowed.path().join("out").display().to_string(), - "dry_run": true + "dry_run": dry_run, + "confirm": confirm }), ); assert_eq!(result["isError"], true, "{result}"); @@ -608,6 +616,102 @@ fn rejects_ios_config_project_symlink_escape() { assert!(text(&result).contains("不在允许的目录内"), "{result}"); } +#[cfg(unix)] +#[test] +fn rejects_ios_project_internal_symlink_escape() { + use std::os::unix::fs::symlink; + + let allowed = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(allowed.path()); + let project = allowed.path().join("Nested.xcodeproj"); + fs::create_dir_all(&project).unwrap(); + let private = outside.path().join("private.pbxproj"); + fs::write(&private, b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.outside;").unwrap(); + symlink(&private, project.join("project.pbxproj")).unwrap(); + let config = allowed.path().join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n", + project.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + for (tool, arguments) in [ + ( + "shellsmith_protect_ios", + json!({ + "ios_config": config.display().to_string(), + "output": allowed.path().join("out").display().to_string(), + "dry_run": true + }), + ), + ( + "shellsmith_check_ios", + json!({"project": project.display().to_string()}), + ), + ] { + let result = client.call(tool, arguments); + 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(); + let cli = write_fake_cli(allowed.path()); + let project = allowed.path().join("App.xcodeproj"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("project.pbxproj"), b"// inside").unwrap(); + let config = allowed.path().join("ios.toml"); + fs::write( + &config, + format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\nentrypoint = \"missing/App.swift\"\n[protection]\nprofile = \"compat\"\n[confidential]\nenabled = true\nconfig = \"missing/secrets.yml\"\n", + project.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + let result = 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!(result["isError"], false, "{result}"); + let job_id = result["structuredContent"]["job_id"].as_str().unwrap(); + let job = client.wait_job(job_id); + assert_eq!(job["structuredContent"]["status"], "succeeded", "{job}"); +} + +#[test] +fn check_ios_preserves_complete_pretty_json_report() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let project = temp.path().join("App.xcodeproj"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("project.pbxproj"), b"// inside").unwrap(); + + let mut client = McpClient::start(&cli, &[temp.path()], &[]); + let result = client.call( + "shellsmith_check_ios", + json!({"project": project.display().to_string()}), + ); + assert_eq!(result["isError"], false, "{result}"); + let structured = &result["structuredContent"]; + assert_eq!(structured["project_path"], "/tmp/App.xcodeproj"); + assert_eq!(structured["schemes"][0], "Audit"); + assert!(structured["checks"].as_array().unwrap().is_empty()); +} + #[cfg(unix)] #[test] fn rejects_output_symlink_escape() { diff --git a/docs/mcp.md b/docs/mcp.md index 5592c65..98a1c5e 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` 也会单独校验,不因配置文件本身在允许目录内而放行外部工程。 +- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验,不因配置文件或工程目录本身在允许目录内而放行外部文件。 - **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。 - **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。 - **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。