use std::path::{Path, PathBuf};
use tracing::warn;
use trusty_common::github_path::parse_github_path;
use crate::core::remote_url_redact::redact_stored_url;
use crate::core::trusty_tools_config::{TrustyToolsConfig, workspace_root, workspace_subpath};
use crate::project::{Project, ProjectRegistry, derive_name_from_url};
const MIN_ROOT_COMPONENTS: usize = 3;
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(':')
}
fn registered_local_root(project: &Project, config: &TrustyToolsConfig) -> Option<PathBuf> {
let declared = if looks_like_remote_url(&project.repo_url) {
workspace_subpath(config, &parse_github_path(&project.repo_url)?)
} else {
PathBuf::from(&project.repo_url)
};
let root = std::fs::canonicalize(declared).ok()?;
if let Some(reason) = degenerate_root_reason(&root, config) {
warn!(
project = %project.name,
root = %root.display(),
"MCP spawn gate: ignoring registered project root — {reason}"
);
return None;
}
Some(root)
}
fn degenerate_root_reason(root: &Path, config: &TrustyToolsConfig) -> Option<&'static str> {
if root.components().count() < MIN_ROOT_COMPONENTS {
return Some("too few path components to be a project checkout");
}
if dirs::home_dir().is_some_and(|home| same_path(&home, root)) {
return Some("it is the operator's home directory");
}
if same_path(&workspace_root(config), root) {
return Some("it is the daemon's workspace root");
}
None
}
fn same_path(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
#[must_use]
pub fn is_known_repo(projects: &[Project], config: &TrustyToolsConfig, 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 Ok(target) = std::fs::canonicalize(repo_url) else {
return false;
};
projects
.iter()
.any(|p| registered_local_root(p, config).is_some_and(|root| target.starts_with(root)))
}
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, config, repo_url) {
let shown = redact_stored_url(repo_url);
let name_hint = derive_name_from_url(&shown).unwrap_or_else(|| shown.to_string());
return Err(format!(
"refusing MCP-initiated spawn for unregistered repo `{shown}`; register it \
first with the `project_register` MCP tool (name=\"{name_hint}\", \
repo_url=\"{shown}\") 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 {
key: &'static str,
prev: Option<String>,
}
impl EnvGuard {
fn set(value: &str) -> Self {
Self::set_key(ALLOW_MCP_SPAWN_ENV, value)
}
fn unset() -> Self {
Self::unset_key(ALLOW_MCP_SPAWN_ENV)
}
fn set_key(key: &'static str, value: &str) -> Self {
let prev = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self { key, prev }
}
fn unset_key(key: &'static str) -> Self {
let prev = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
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,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: 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 local_path_allowlist_decides_on_the_canonical_full_path() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path();
let registered = root.join("checkouts").join("trusty-tools");
let worktree = registered.join(".claude").join("worktrees").join("agent-x");
let sibling = root.join("checkouts").join("trusty-tools-evil");
let elsewhere = root.join("evil").join("trusty-tools");
for dir in [&worktree, &sibling, &elsewhere] {
std::fs::create_dir_all(dir).expect("create fixture dir");
}
let link = root.join("link-to-checkout");
std::os::unix::fs::symlink(®istered, &link).expect("symlink the registered checkout");
let projects = vec![make_project("trusty-tools", ®istered.to_string_lossy())];
let cfg = TrustyToolsConfig::default();
let cases: Vec<(&str, PathBuf, bool)> = vec![
("the registered checkout itself", registered.clone(), true),
("a symlink to the registered checkout", link, true),
("a worktree inside the registered checkout", worktree, true),
(
"a same-basename directory elsewhere on disk",
elsewhere,
false,
),
(
"a sibling whose name merely starts with the root's",
sibling,
false,
),
(
"a path that does not exist",
registered.join("missing"),
false,
),
(
"a `..` escape out of the registered checkout",
registered.join("..").join("trusty-tools-evil"),
false,
),
];
for (what, path, expected) in cases {
assert_eq!(
is_known_repo(&projects, &cfg, &path.to_string_lossy()),
expected,
"{what}: {}",
path.display()
);
}
}
#[test]
fn a_remote_registered_project_is_known_at_its_workspace_checkout() {
let _g = crate::core::trusty_tools_config::env_test_lock();
let _env = EnvGuard::unset_key(trusty_common::workspace_layout::WORKSPACE_ROOT_ENV);
let tmp = TempDir::new().expect("tempdir");
let workspace = tmp.path().join("trusty-mpm-projects");
let checkout = workspace.join("bobmatnyc").join("trusty-tools");
let impostor = tmp.path().join("elsewhere").join("trusty-tools");
for dir in [&checkout, &impostor] {
std::fs::create_dir_all(dir).expect("create fixture dir");
}
let projects = vec![make_project(
"trusty-tools",
"https://github.com/bobmatnyc/trusty-tools",
)];
let cfg = TrustyToolsConfig {
workspace_root_template: Some(workspace.to_string_lossy().into_owned()),
..Default::default()
};
assert!(
is_known_repo(&projects, &cfg, &checkout.to_string_lossy()),
"the project's own workspace checkout must be known"
);
assert!(
!is_known_repo(&projects, &cfg, &impostor.to_string_lossy()),
"a same-name directory outside the workspace checkout must not be"
);
}
#[test]
#[serial]
fn a_degenerate_registered_root_is_never_a_containment_root() {
let tmp = TempDir::new().expect("tempdir");
let home = tmp.path().join("home").join("op");
let unrelated = home.join("checkouts").join("aria");
let real = tmp.path().join("checkouts").join("trusty-tools");
let real_worktree = real.join(".worktrees").join("agent-x");
for dir in [&unrelated, &real_worktree] {
std::fs::create_dir_all(dir).expect("create fixture dir");
}
let _home = EnvGuard::set_key("HOME", &home.to_string_lossy());
let projects = vec![
make_project("filesystem-root", "/"),
make_project("home-dir", &home.to_string_lossy()),
make_project("trusty-tools", &real.to_string_lossy()),
];
let cfg = TrustyToolsConfig::default();
assert!(
!is_known_repo(&projects, &cfg, &unrelated.to_string_lossy()),
"a path outside every real project must not be known via `/` or $HOME"
);
assert!(
is_known_repo(&projects, &cfg, &real_worktree.to_string_lossy()),
"a worktree of a genuinely registered checkout must stay known"
);
}
#[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,
&TrustyToolsConfig::default(),
"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,
&TrustyToolsConfig::default(),
"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,
&TrustyToolsConfig::default(),
"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,
&TrustyToolsConfig::default(),
"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,
&TrustyToolsConfig::default(),
"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_refusal_masks_a_stored_password_9285() {
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()
};
for (url, masked) in [
(
"https://u:pa'ss9285@github.com/acme/widget",
"https://***@github.com/acme/widget",
),
(
"https://u:pa ss9285@github.com/acme/widget",
"https://***@github.com/acme/widget",
),
("https://u:pa'ss9285@host/", "https://***@host/"),
("https://u:pa/ss9285@host", "https://***@host"),
("u:pw9285@host:repo", "***@host:repo"),
] {
let err = ensure_mcp_spawn_allowed(®istry, &cfg, url)
.await
.expect_err("an unregistered repo is refused");
assert!(
!err.contains("ss9285") && !err.contains("pw9285"),
"a password fragment survived: {err}"
);
assert!(
!err.contains("u:pa") && !err.contains("u:pw"),
"the user:password pair survived: {err}"
);
assert!(err.contains(&format!("repo `{masked}`")), "{err}");
assert!(err.contains(&format!("repo_url=\"{masked}\"")), "{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");
}
}