diff --git a/Cargo.lock b/Cargo.lock index e106cb7..db74bb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3657,6 +3657,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "toml 0.8.2", ] [[package]] diff --git a/apps/shellsmith-mcp/Cargo.toml b/apps/shellsmith-mcp/Cargo.toml index 7ef1529..c016638 100644 --- a/apps/shellsmith-mcp/Cargo.toml +++ b/apps/shellsmith-mcp/Cargo.toml @@ -17,6 +17,7 @@ clap = { version = "4", features = ["derive", "env"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3.20" +toml = "0.8" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/apps/shellsmith-mcp/src/jobs.rs b/apps/shellsmith-mcp/src/jobs.rs index a425c10..608a740 100644 --- a/apps/shellsmith-mcp/src/jobs.rs +++ b/apps/shellsmith-mcp/src/jobs.rs @@ -121,7 +121,7 @@ pub struct Server { impl Server { pub fn new(cli: PathBuf, allowed_roots: Vec) -> Self { Self { - cli, + cli: resolve_cli_path(cli), allowed_roots, out: Arc::new(Mutex::new(BufWriter::new(std::io::stdout()))), jobs: Mutex::new(BTreeMap::new()), @@ -297,6 +297,11 @@ impl Server { set_cli_working_dir(&mut process, &self.cli); apply_sanitized_env(&mut process, env); add_temp_env(&mut process, sync_temp.path()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + process.process_group(0); + } let mut child = process .spawn() .with_context(|| format!("启动 Shellsmith CLI 失败: {}", self.cli.display()))?; @@ -758,6 +763,23 @@ fn set_cli_working_dir(process: &mut Command, cli: &Path) { } } +fn resolve_cli_path(cli: PathBuf) -> PathBuf { + if cli.is_absolute() { + return cli; + } + let has_parent = cli + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()); + if !has_parent { + return cli; + } + let Ok(cwd) = std::env::current_dir() else { + return cli; + }; + let resolved = cwd.join(cli); + resolved.canonicalize().unwrap_or(resolved) +} + fn add_temp_env(process: &mut Command, root: &Path) { let root = root.to_string_lossy().into_owned(); process diff --git a/apps/shellsmith-mcp/src/tools.rs b/apps/shellsmith-mcp/src/tools.rs index c53c142..5fba429 100644 --- a/apps/shellsmith-mcp/src/tools.rs +++ b/apps/shellsmith-mcp/src/tools.rs @@ -393,7 +393,7 @@ struct RuntimeUiInput { ui_packer: Option, } -#[derive(Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Default)] struct SignInput { input: Option, output: Option, @@ -899,7 +899,10 @@ fn protect_apk( env: env.clone(), label: "加固 APK".into(), }]; - if let Some(sign) = input.sign { + if let Some(mut sign) = input.sign { + if sign.apksigner.is_none() { + sign.apksigner = input.apksigner.clone(); + } let sign_args = sign_command_args( server, &sign, @@ -1263,6 +1266,7 @@ fn protect_ios( require_confirm(input.confirm)?; } let config = server.resolve_input(&input.ios_config, "iOS 配置")?; + validate_ios_config_paths(server, &config)?; let output = server.resolve_output(&input.output, "iOS 输出目录")?; let mut cli_args = vec![ "protect-ios".to_string(), @@ -1700,6 +1704,71 @@ fn push_path_opt( Ok(()) } +fn resolve_config_path(base: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + base.join(path) + } +} + +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)?; + } + Ok(()) +} + +/// iOS 配置中的工程、entrypoint 和 Swift Confidential 路径也必须受允许目录约束。 +fn validate_ios_config_paths(server: &Arc, config: &Path) -> Result<()> { + let content = fs::read_to_string(config) + .with_context(|| format!("读取 iOS 配置失败:{}", config.display()))?; + let value: toml::Value = toml::from_str(&content).context("解析 iOS 配置失败")?; + let base = config.parent().unwrap_or(Path::new(".")); + + let project = value + .get("project") + .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 工程")?; + + 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); + ensure_config_path_allowed( + server, + &resolve_config_path(project_root, entrypoint), + "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), + "Swift Confidential 配置", + )?; + } + } + } + + Ok(()) +} + fn push_opt(args: &mut Vec, flag: &str, value: Option<&str>) { if let Some(value) = value { if !value.is_empty() { diff --git a/apps/shellsmith-mcp/tests/mcp_stdio.rs b/apps/shellsmith-mcp/tests/mcp_stdio.rs index e56a017..4487200 100644 --- a/apps/shellsmith-mcp/tests/mcp_stdio.rs +++ b/apps/shellsmith-mcp/tests/mcp_stdio.rs @@ -19,7 +19,23 @@ struct McpClient { impl McpClient { fn start(cli: &Path, allow_roots: &[&Path], envs: &[(&str, &str)]) -> Self { + Self::start_with_cwd(None, cli, allow_roots, envs) + } + + fn start_in(cwd: &Path, cli: &Path, allow_roots: &[&Path], envs: &[(&str, &str)]) -> Self { + Self::start_with_cwd(Some(cwd), cli, allow_roots, envs) + } + + fn start_with_cwd( + cwd: Option<&Path>, + cli: &Path, + allow_roots: &[&Path], + envs: &[(&str, &str)], + ) -> Self { let mut command = Command::new(env!("CARGO_BIN_EXE_shellsmith-mcp")); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } command.arg("--cli").arg(cli); for root in allow_roots { command.arg("--allow-root").arg(root); @@ -221,6 +237,9 @@ case "$cmd" in printf '{"already_protected":false,"is_signed":true,"cert_fingerprint":"%s","error":null}\n' "$cert" ;; check-aab) + if [ -n "${FAKE_GRANDCHILD_MARKER:-}" ]; then + ( sleep 2; echo late > "$FAKE_GRANDCHILD_MARKER" ) & + fi sleep "${FAKE_CHECK_SLEEP:-0}" echo '{"has_base":true,"modules":[]}' ;; @@ -444,6 +463,151 @@ fn enforces_allowed_roots() { assert!(text(&result).contains("不在允许的目录内")); } +#[test] +fn rejects_ios_config_project_outside_allowed_root() { + let allowed = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(allowed.path()); + let project = outside.path().join("App.xcodeproj"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("project.pbxproj"), b"// outside").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()], &[]); + 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"], true, "{result}"); + assert!(text(&result).contains("不在允许的目录内"), "{result}"); +} + +#[test] +fn rejects_ios_config_auxiliary_paths_outside_allowed_root() { + let allowed = tempfile::tempdir().unwrap(); + let outside = 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 outside_file = outside.path().join("outside.txt"); + fs::write(&outside_file, b"outside").unwrap(); + + let base = format!( + "[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n", + project.display() + ); + let entrypoint_config = allowed.path().join("ios-entrypoint.toml"); + fs::write( + &entrypoint_config, + format!("{base}entrypoint = \"{}\"\n", outside_file.display()), + ) + .unwrap(); + let confidential_config = allowed.path().join("ios-confidential.toml"); + fs::write( + &confidential_config, + format!( + "{base}[confidential]\nenabled = true\nconfig = \"{}\"\n", + outside_file.display() + ), + ) + .unwrap(); + + let mut client = McpClient::start(&cli, &[allowed.path()], &[]); + for config in [entrypoint_config, confidential_config] { + 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"], true, "{result}"); + assert!(text(&result).contains("不在允许的目录内"), "{result}"); + } +} + +#[test] +fn allows_ios_config_project_inside_allowed_root() { + 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\"]\n[protection]\nprofile = \"compat\"\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}"); +} + +#[cfg(unix)] +#[test] +fn rejects_ios_config_project_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 outside_project = outside.path().join("App.xcodeproj"); + fs::create_dir_all(&outside_project).unwrap(); + fs::write(outside_project.join("project.pbxproj"), b"// outside").unwrap(); + let link = allowed.path().join("Linked.xcodeproj"); + symlink(&outside_project, &link).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", + link.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"], true, "{result}"); + assert!(text(&result).contains("不在允许的目录内"), "{result}"); +} + #[cfg(unix)] #[test] fn rejects_output_symlink_escape() { @@ -776,6 +940,93 @@ fn sync_check_times_out_and_kills_cli() { ); } +#[cfg(unix)] +#[test] +fn sync_timeout_kills_grandchildren() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let aab = temp.path().join("app.aab"); + let marker = temp.path().join("grandchild-wrote"); + fs::write(&aab, b"aab").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("FAKE_CHECK_SLEEP", "5"), + ("FAKE_GRANDCHILD_MARKER", marker.to_str().unwrap()), + ("SHELLSMITH_MCP_SYNC_TIMEOUT_SECS", "1"), + ], + ); + let result = client.call( + "shellsmith_check_aab", + json!({"path": aab.display().to_string()}), + ); + assert_eq!(result["isError"], true, "{result}"); + assert!(text(&result).contains("超时"), "{result}"); + thread::sleep(Duration::from_millis(2500)); + assert!( + !marker.exists(), + "同步检查超时后孙进程仍继续运行: {}", + marker.display() + ); +} + +#[cfg(unix)] +#[test] +fn closing_stdin_kills_sync_grandchildren() { + let temp = tempfile::tempdir().unwrap(); + let cli = write_fake_cli(temp.path()); + let aab = temp.path().join("app.aab"); + let marker = temp.path().join("grandchild-wrote"); + fs::write(&aab, b"aab").unwrap(); + + let mut client = McpClient::start( + &cli, + &[temp.path()], + &[ + ("FAKE_CHECK_SLEEP", "5"), + ("FAKE_GRANDCHILD_MARKER", marker.to_str().unwrap()), + ], + ); + client.send_request( + "tools/call", + json!({ + "name": "shellsmith_check_aab", + "arguments": {"path": aab.display().to_string()} + }), + ); + thread::sleep(Duration::from_millis(200)); + client.close_stdin(); + assert!( + client.wait_exit(Duration::from_secs(5)).is_some(), + "MCP 未在关闭 stdin 后退出" + ); + thread::sleep(Duration::from_millis(2500)); + assert!( + !marker.exists(), + "MCP 退出后同步检查孙进程仍继续运行: {}", + marker.display() + ); +} + +#[test] +fn starts_relative_cli_path() { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + fs::create_dir(&bin).unwrap(); + write_fake_cli(&bin); + let mut client = McpClient::start_in( + temp.path(), + Path::new("./bin/fake-shield.sh"), + &[temp.path()], + &[], + ); + let result = client.call("shellsmith_version", json!({})); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(result["structuredContent"]["cli_version"], "shield 1.10.1"); +} + #[test] fn offline_protect_does_not_inherit_byos_key() { let temp = tempfile::tempdir().unwrap(); @@ -913,7 +1164,7 @@ fn cancels_running_job_and_kills_cli_subprocess() { } #[test] -fn certificate_precheck_uses_custom_apksigner() { +fn top_level_apksigner_is_used_for_precheck_and_sign() { let temp = tempfile::tempdir().unwrap(); let cli = write_fake_cli(temp.path()); let log = temp.path().join("calls.log"); @@ -950,8 +1201,7 @@ fn certificate_precheck_uses_custom_apksigner() { "keystore": keystore.display().to_string(), "key_alias": "release", "password_env": "TEST_KS_PASS", - "key_password_env": "TEST_KEY_PASS", - "apksigner": apksigner.display().to_string() + "key_password_env": "TEST_KEY_PASS" } }), ); @@ -966,6 +1216,11 @@ fn certificate_precheck_uses_custom_apksigner() { .any(|line| { line.starts_with("check-apk ") && line.contains("--apksigner") }), "证书预检没有使用自定义 apksigner: {logs}" ); + assert!( + logs.lines() + .any(|line| { line.starts_with("sign ") && line.contains("--apksigner") }), + "最终签名没有继承顶层 apksigner: {logs}" + ); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index e883450..5592c65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,7 +44,7 @@ cargo build --release -p shellsmith-mcp | 参数 | 环境变量 | 说明 | |---|---|---| -| `--cli PATH` | `SHELLSMITH_MCP_CLI` | `shield` 可执行文件;默认先找同目录,再查 `PATH` | +| `--cli PATH` | `SHELLSMITH_MCP_CLI` | `shield` 可执行文件;默认先找同目录,再查 `PATH`。相对路径按 MCP 启动目录解析 | | `--allow-root DIR` | `SHELLSMITH_MCP_ALLOWED_ROOTS` | 允许读写的目录,可重复;未配置时不限制路径。环境变量使用平台路径分隔符 | 服务启动后从 stdin 读取 MCP 消息,向 stdout 写响应;诊断信息只写 stderr。 @@ -214,9 +214,9 @@ exec /opt/Shellsmith/bin/shellsmith-mcp --allow-root "$HOME/projects" ## 安全边界 - **密码**:只从 MCP 进程环境读取;工具 schema 中没有 `password`、`ks_pass`、`key_password` 字段。错误信息不会回显密码。 -- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。 +- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、`project.entrypoint` 和 `confidential.config` 也会单独校验,不因配置文件本身在允许目录内而放行外部工程。 - **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。 -- **取消**:取消会终止 `shield` 子进程;已写出的临时文件由 CLI 自身清理。 +- **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。 - **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。 - **报告**:`shellsmith_read_report` 只读本地 JSON,不执行报告中的任何内容。