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
38 changes: 37 additions & 1 deletion apps/shellsmith-mcp/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,10 @@ fn notify_progress(server: &Server, job: &Arc<Mutex<Job>>) {

fn parse_json_output(bytes: &[u8]) -> Result<Value> {
let text = String::from_utf8_lossy(bytes);
let trimmed = text.trim();
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
return Ok(value);
}
for line in text.lines().rev() {
let line = line.trim();
if line.is_empty() {
Expand All @@ -904,7 +908,7 @@ fn parse_json_output(bytes: &[u8]) -> Result<Value> {
return Ok(value);
}
}
Ok(Value::String(text.trim().to_string()))
Ok(Value::String(trimmed.to_string()))
}

fn absolute_path(value: &str) -> Result<PathBuf> {
Expand All @@ -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()));
}
}
162 changes: 133 additions & 29 deletions apps/shellsmith-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1713,17 +1714,106 @@ fn resolve_config_path(base: &Path, value: &str) -> PathBuf {
}
}

fn ensure_config_path_allowed(server: &Arc<Server>, 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<Server>, 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<Server>,
root: &Path,
depth: usize,
output: &mut Vec<PathBuf>,
) -> 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<Server>, 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<Server>, config: &Path) -> Result<()> {
fn validate_ios_config_paths(server: &Arc<Server>, 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 配置失败")?;
Expand All @@ -1734,32 +1824,46 @@ fn validate_ios_config_paths(server: &Arc<Server>, 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 配置",
)?;
}
Expand Down
108 changes: 106 additions & 2 deletions apps/shellsmith-mcp/tests/mcp_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -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() {
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` 也会单独校验,不因配置文件本身在允许目录内而放行外部工程。
- **路径**:输入路径必须存在并会被规范化;输出父目录必须存在;配置 `--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 服务。
Expand Down
Loading