Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 67 additions & 12 deletions apps/shellsmith-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,9 +608,11 @@ fn dispatch(
"shellsmith_check_aab" => check_aab(server, args),
"shellsmith_check_ios" => {
let input: CheckIosInput = parse(args)?;
let entry = absolute_entry_path(&input.project)?;
let entry_kind = entry.extension().and_then(|value| value.to_str());
let project = server.resolve_input(&input.project, "Xcode 工程")?;
validate_ios_project(server, &project, true)?;
let mut cli_args = vec!["check-ios".to_string(), project.display().to_string()];
validate_ios_project(server, &project, entry_kind, true)?;
let mut cli_args = vec!["check-ios".to_string(), entry.display().to_string()];
if let Some(scheme) = input.scheme {
cli_args.push("--scheme".into());
cli_args.push(scheme);
Expand Down Expand Up @@ -1715,6 +1717,15 @@ fn resolve_config_path(base: &Path, value: &str) -> PathBuf {
}
}

fn absolute_entry_path(value: &str) -> Result<PathBuf> {
let path = PathBuf::from(value);
if path.is_absolute() {
Ok(path)
} else {
Ok(std::env::current_dir()?.join(path))
}
}

const MAX_IOS_BUNDLE_ENTRIES: usize = 50_000;
const MAX_IOS_BUNDLE_DEPTH: usize = 32;

Expand Down Expand Up @@ -1847,14 +1858,16 @@ fn ensure_ios_source_root_allowed(server: &Arc<Server>, project: &Path) -> Resul
fn validate_ios_project(
server: &Arc<Server>,
project: &Path,
entry_kind: Option<&str>,
require_source_root: bool,
) -> Result<()> {
match project.extension().and_then(|value| value.to_str()) {
match entry_kind {
Some("xcodeproj") => {
if require_source_root {
ensure_ios_source_root_allowed(server, project)?;
}
validate_ios_bundle(server, project)
validate_ios_bundle(server, project)?;
validate_ios_cocoapods_paths(server, project)
}
Some("xcworkspace") => {
ensure_ios_source_root_allowed(server, project)?;
Expand All @@ -1866,12 +1879,55 @@ fn validate_ios_project(
validate_ios_bundle(server, &project)?;
}
}
Ok(())
validate_ios_cocoapods_paths(server, project)
}
_ => Ok(()),
_ => anyhow::bail!(
"iOS 输入必须是 .xcodeproj 或 .xcworkspace: {}",
project.display()
),
}
}

/// CocoaPods 预检会直接读取源码根目录的锁文件;这些路径也必须受允许目录约束。
fn validate_ios_cocoapods_paths(server: &Arc<Server>, project: &Path) -> Result<()> {
let Some(parent) = project.parent() else {
return Ok(());
};
let internal_workspace =
parent.extension().and_then(|value| value.to_str()) == Some("xcodeproj");
let root = if internal_workspace {
parent.parent().unwrap_or(parent)
} else {
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 uses_pods = root.join("Podfile").is_file()
|| root.join("podfile").is_file()
|| root.join("Podfile.lock").is_file()
|| pbx.contains("PODS_ROOT")
|| pbx.contains("[CP] Check Pods Manifest.lock");
if !uses_pods {
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"),
(
root.join("Pods/Pods.xcodeproj/project.pbxproj"),
"CocoaPods Pods 工程",
),
] {
if path.exists() {
server.resolve_input(&path.to_string_lossy(), label)?;
}
}
Ok(())
}

/// iOS 配置中的工程、entrypoint 和 Swift Confidential 路径也必须受允许目录约束。
fn validate_ios_config_paths(server: &Arc<Server>, config: &Path, dry_run: bool) -> Result<()> {
let content = fs::read_to_string(config)
Expand All @@ -1884,11 +1940,10 @@ fn validate_ios_config_paths(server: &Arc<Server>, config: &Path, dry_run: bool)
.and_then(|project| project.get("path"))
.and_then(toml::Value::as_str)
.context("iOS 配置缺少 project.path")?;
let project_path = server.resolve_input(
&resolve_config_path(base, project).to_string_lossy(),
"iOS 工程",
)?;
validate_ios_project(server, &project_path, true)?;
let project_entry = resolve_config_path(base, project);
let entry_kind = project_entry.extension().and_then(|value| value.to_str());
let project_path = server.resolve_input(&project_entry.to_string_lossy(), "iOS 工程")?;
validate_ios_project(server, &project_path, entry_kind, true)?;

let profile = value
.get("protection")
Expand All @@ -1903,7 +1958,7 @@ fn validate_ios_config_paths(server: &Arc<Server>, config: &Path, dry_run: bool)
.and_then(|project| project.get("entrypoint"))
.and_then(toml::Value::as_str)
{
let project_root = project_path.parent().unwrap_or(base);
let project_root = project_entry.parent().unwrap_or(base);
server.resolve_input(
&resolve_config_path(project_root, entrypoint).to_string_lossy(),
"iOS entrypoint",
Expand Down
115 changes: 110 additions & 5 deletions apps/shellsmith-mcp/tests/mcp_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,8 +864,82 @@ fn checks_project_aliases_by_entry_name() {
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 alias = allowed.path().join("Alias.xcodeproj");
symlink(&target, &alias).unwrap();
let cli = write_fake_cli(allowed.path());
let alias_config = allowed.path().join("ios-alias.toml");
fs::write(
&alias_config,
format!(
"[project]\npath = \"{}\"\nscheme = \"Audit\"\nteam_id = \"ABCDEFGHIJ\"\nbundle_ids = [\"com.example.audit\"]\n[protection]\nprofile = \"compat\"\n",
alias.display()
),
)
.unwrap();

let mut client = McpClient::start(&cli, &[allowed.path()], &[]);
let check = client.call(
"shellsmith_check_ios",
json!({"project": alias.display().to_string()}),
);
assert_eq!(check["isError"], true, "{check}");
assert!(text(&check).contains("不在允许的目录内"), "{check}");
let protect = client.call(
"shellsmith_protect_ios",
json!({
"ios_config": alias_config.display().to_string(),
"output": allowed.path().join("alias-out").display().to_string(),
"dry_run": true
}),
);
assert_eq!(protect["isError"], true, "{protect}");
assert!(text(&protect).contains("不在允许的目录内"), "{protect}");

fs::remove_file(target.join("project.pbxproj")).unwrap();
fs::write(
target.join("project.pbxproj"),
b"PRODUCT_BUNDLE_IDENTIFIER = com.audit.inside;",
)
.unwrap();
let check = client.call(
"shellsmith_check_ios",
json!({"project": alias.display().to_string()}),
);
assert_eq!(check["isError"], false, "{check}");
let protect = client.call(
"shellsmith_protect_ios",
json!({
"ios_config": alias_config.display().to_string(),
"output": allowed.path().join("alias-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() {
use std::os::unix::fs::symlink;

let sample = tempfile::tempdir().unwrap();
let outside = 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();
let outside_lock = outside.path().join("external.lock");
fs::write(&outside_lock, b"PODS:\n - Audit\n").unwrap();
let podfile_lock = sample.path().join("Podfile.lock");
let manifest_lock = sample.path().join("Pods/Manifest.lock");
let cli = write_fake_cli(sample.path());
let config = workspace.join("ios.toml");
fs::write(
&config,
Expand All @@ -876,13 +950,44 @@ fn checks_project_aliases_by_entry_name() {
)
.unwrap();

let mut client = McpClient::start(&cli, &[allowed.path()], &[]);
let result = client.call(
let mut client = McpClient::start(&cli, &[sample.path()], &[]);
symlink(&outside_lock, &podfile_lock).unwrap();
fs::write(&manifest_lock, b"PODS:\n - Audit\n").unwrap();
let check = client.call(
"shellsmith_check_ios",
json!({"project": workspace.display().to_string()}),
);
assert_eq!(result["isError"], true, "{result}");
assert!(text(&result).contains("不在允许的目录内"), "{result}");
assert_eq!(check["isError"], true, "{check}");
assert!(text(&check).contains("不在允许的目录内"), "{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"], true, "{protect}");
assert!(text(&protect).contains("不在允许的目录内"), "{protect}");

fs::remove_file(&podfile_lock).unwrap();
fs::write(&podfile_lock, b"PODS:\n - Audit\n").unwrap();
fs::remove_file(&manifest_lock).unwrap();
symlink(&outside_lock, &manifest_lock).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(&manifest_lock).unwrap();
fs::write(&manifest_lock, b"PODS:\n - Audit\n").unwrap();
let check = client.call(
"shellsmith_check_ios",
json!({"project": workspace.display().to_string()}),
);
assert_eq!(check["isError"], false, "{check}");
}

#[cfg(unix)]
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 本身会被明确拒绝。bundle 内与 CLI 实际读取无关的失效链接会被忽略。
- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--allow-root` 后,所有输入和输出都必须位于允许目录内。iOS 配置中的 `project.path`、实际启用的 `project.entrypoint`/`confidential.config`,以及 `.xcodeproj`/`.xcworkspace` bundle 内部的符号链接都会单独校验。iOS 检查/保护还要求工程源码根目录在允许范围内;只允许 bundle 本身会被明确拒绝。CocoaPods 的 `Podfile.lock`、`Pods/Manifest.lock` 等实际读取文件也会校验;bundle 内与 CLI 实际读取无关的失效链接会被忽略。
- **确认**:加固、签名、安装要求 `confirm: true`;原地覆盖还要求 `allow_in_place: true`。
- **取消**:异步任务和同步检查都会终止整个 CLI 进程树;MCP 持有任务私有 `TMPDIR`/暂存根目录,取消或退出后会清理口令与 staging。回滚失败时 CLI 明确保留的恢复副本位于输出目录,不会被 MCP 删除。
- **不上传**:MCP 不包含遥测、更新检查或远程托管逻辑。只有显式使用 BYOS 时才会访问你配置的 HTTPS 服务。
Expand Down
Loading