use trusty_common::github_path::parse_github_path;
use crate::core::trusty_tools_config::TrustyToolsConfig;
use crate::project::{Project, ProjectRegistry, derive_name_from_url};
pub const ALLOW_MCP_SPAWN_ENV: &str = "TRUSTY_MPM_ALLOW_MCP_SPAWN";
#[must_use]
pub fn mcp_spawn_enabled(config: &TrustyToolsConfig) -> bool {
if let Ok(raw) = std::env::var(ALLOW_MCP_SPAWN_ENV) {
let raw = raw.trim().to_ascii_lowercase();
match raw.as_str() {
"1" | "true" | "yes" => return true,
"0" | "false" | "no" => return false,
_ => {} }
}
config
.daemon
.as_ref()
.and_then(|d| d.allow_mcp_spawn)
.unwrap_or(false)
}
fn looks_like_remote_url(s: &str) -> bool {
s.contains("://") || s.contains(':')
}
#[must_use]
pub fn is_known_repo(projects: &[Project], repo_url: &str) -> bool {
if looks_like_remote_url(repo_url) {
let Some(target) = parse_github_path(repo_url) else {
return false;
};
return projects.iter().any(|p| {
looks_like_remote_url(&p.repo_url)
&& parse_github_path(&p.repo_url).is_some_and(|gh| {
gh.owner.eq_ignore_ascii_case(&target.owner)
&& gh.repo.eq_ignore_ascii_case(&target.repo)
})
});
}
let target_name = derive_name_from_url(repo_url);
projects
.iter()
.any(|p| target_name.as_deref() == Some(p.name.as_str()))
}
pub async fn ensure_mcp_spawn_allowed(
registry: &ProjectRegistry,
config: &TrustyToolsConfig,
repo_url: &str,
) -> Result<(), String> {
if !mcp_spawn_enabled(config) {
return Err(format!(
"managed spawning via MCP is disabled; enable it with `allow_mcp_spawn: true` \
under `daemon:` in ~/.trusty-tools/trusty-mpm/config.yaml, or set \
{ALLOW_MCP_SPAWN_ENV}=1 — or run `tm launch` directly from the CLI, which is \
never gated"
));
}
let projects = registry
.list()
.await
.map_err(|e| format!("failed to read project registry: {e}"))?;
if !is_known_repo(&projects, repo_url) {
let name_hint = derive_name_from_url(repo_url).unwrap_or_else(|| repo_url.to_string());
return Err(format!(
"refusing MCP-initiated spawn for unregistered repo `{repo_url}`; register it \
first with the `project_register` MCP tool (name=\"{name_hint}\", \
repo_url=\"{repo_url}\") or add it under `projects:` in \
~/.trusty-tools/trusty-mpm/config.yaml, then retry"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::trusty_tools_config::DaemonConfig;
use serial_test::serial;
use tempfile::TempDir;
struct EnvGuard {
prev: Option<String>,
}
impl EnvGuard {
fn set(value: &str) -> Self {
let prev = std::env::var(ALLOW_MCP_SPAWN_ENV).ok();
unsafe { std::env::set_var(ALLOW_MCP_SPAWN_ENV, value) };
Self { prev }
}
fn unset() -> Self {
let prev = std::env::var(ALLOW_MCP_SPAWN_ENV).ok();
unsafe { std::env::remove_var(ALLOW_MCP_SPAWN_ENV) };
Self { prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var(ALLOW_MCP_SPAWN_ENV, v),
None => std::env::remove_var(ALLOW_MCP_SPAWN_ENV),
}
}
}
}
fn make_project(name: &str, repo_url: &str) -> Project {
Project {
name: name.to_string(),
repo_url: repo_url.to_string(),
default_branch: "main".into(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
github: None,
commit_name: None,
commit_email: None,
}
}
#[test]
#[serial]
fn default_is_disabled() {
let _env = EnvGuard::unset();
assert!(!mcp_spawn_enabled(&TrustyToolsConfig::default()));
}
#[test]
#[serial]
fn config_true_enables() {
let _env = EnvGuard::unset();
let cfg = TrustyToolsConfig {
daemon: Some(DaemonConfig {
allow_mcp_spawn: Some(true),
}),
..Default::default()
};
assert!(mcp_spawn_enabled(&cfg));
}
#[test]
#[serial]
fn env_true_enables_regardless_of_config() {
let _env = EnvGuard::set("1");
assert!(
mcp_spawn_enabled(&TrustyToolsConfig::default()),
"env=1 must enable even with no config set"
);
}
#[test]
#[serial]
fn env_false_string_force_disables_regardless_of_config() {
let _env = EnvGuard::set("0");
let cfg = TrustyToolsConfig {
daemon: Some(DaemonConfig {
allow_mcp_spawn: Some(true),
}),
..Default::default()
};
assert!(
!mcp_spawn_enabled(&cfg),
"an explicit env=0 must force-disable even when config=true"
);
}
#[test]
#[serial]
fn env_unset_defers_to_config() {
let _env = EnvGuard::unset();
let cfg = TrustyToolsConfig {
daemon: Some(DaemonConfig {
allow_mcp_spawn: Some(true),
}),
..Default::default()
};
assert!(
mcp_spawn_enabled(&cfg),
"env unset must defer to config=true"
);
}
#[test]
#[serial]
fn env_empty_string_defers_to_config() {
let _env = EnvGuard::set("");
let cfg = TrustyToolsConfig {
daemon: Some(DaemonConfig {
allow_mcp_spawn: Some(true),
}),
..Default::default()
};
assert!(mcp_spawn_enabled(&cfg), "env='' must defer to config=true");
}
#[test]
fn looks_like_remote_url_detects_https_and_ssh() {
assert!(looks_like_remote_url("https://github.com/owner/repo"));
assert!(looks_like_remote_url("git@github.com:owner/repo.git"));
}
#[test]
fn looks_like_remote_url_rejects_local_absolute_path() {
assert!(!looks_like_remote_url("/Users/op/checkouts/aria"));
}
#[test]
fn is_known_repo_matches_by_name() {
let projects = vec![make_project("aria", "https://github.com/duettoresearch/x")];
assert!(is_known_repo(&projects, "/Users/op/checkouts/aria"));
}
#[test]
fn is_known_repo_matches_by_owner_and_repo_ignoring_git_suffix() {
let projects = vec![make_project(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
)];
assert!(is_known_repo(
&projects,
"https://github.com/bobmatnyc/trusty-tools.git"
));
}
#[test]
fn is_known_repo_matches_ssh_and_https_forms() {
let projects = vec![make_project(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
)];
assert!(is_known_repo(
&projects,
"git@github.com:bobmatnyc/trusty-tools.git"
));
}
#[test]
fn is_known_repo_rejects_unregistered() {
let projects = vec![make_project(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
)];
assert!(!is_known_repo(
&projects,
"https://github.com/duettoresearch/aria"
));
}
#[test]
fn is_known_repo_rejects_same_repo_name_different_owner() {
let projects = vec![make_project(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
)];
assert!(!is_known_repo(
&projects,
"https://evil.example.com/attacker/trusty-tools"
));
}
#[test]
fn is_known_repo_remote_target_ignores_local_registered_url() {
let projects = vec![make_project("weird", "/some/local/path/repo")];
assert!(!is_known_repo(&projects, "https://github.com/some/repo"));
}
#[tokio::test]
#[serial]
async fn ensure_mcp_spawn_allowed_disabled_by_default() {
let _env = EnvGuard::unset();
let dir = TempDir::new().expect("tempdir");
let registry = ProjectRegistry::load(dir.path()).await.expect("load");
let cfg = TrustyToolsConfig::default();
let err =
ensure_mcp_spawn_allowed(®istry, &cfg, "https://github.com/duettoresearch/aria")
.await
.expect_err("must refuse when MCP spawning is disabled by default");
assert!(err.contains("disabled"), "{err}");
assert!(err.contains("allow_mcp_spawn"), "{err}");
}
#[tokio::test]
#[serial]
async fn ensure_mcp_spawn_allowed_enabled_but_unregistered() {
let _env = EnvGuard::unset();
let dir = TempDir::new().expect("tempdir");
let registry = ProjectRegistry::load(dir.path()).await.expect("load");
let cfg = TrustyToolsConfig {
daemon: Some(DaemonConfig {
allow_mcp_spawn: Some(true),
}),
..Default::default()
};
let err =
ensure_mcp_spawn_allowed(®istry, &cfg, "https://github.com/duettoresearch/aria")
.await
.expect_err("must refuse an unregistered repo even when spawning is enabled");
assert!(err.contains("unregistered"), "{err}");
assert!(err.contains("project_register"), "{err}");
}
#[tokio::test]
#[serial]
async fn ensure_mcp_spawn_allowed_enabled_and_registered() {
let _env = EnvGuard::unset();
let dir = TempDir::new().expect("tempdir");
let registry = ProjectRegistry::load(dir.path()).await.expect("load");
registry
.register(make_project(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
))
.await
.expect("register");
let cfg = TrustyToolsConfig {
daemon: Some(DaemonConfig {
allow_mcp_spawn: Some(true),
}),
..Default::default()
};
ensure_mcp_spawn_allowed(
®istry,
&cfg,
"https://github.com/bobmatnyc/trusty-tools.git",
)
.await
.expect("an already-registered project must spawn without extra ceremony");
}
}