#[cfg_attr(unix, path = "fs_helpers.rs")]
#[cfg_attr(not(unix), path = "fs_helpers_windows.rs")]
mod fs_helpers;
mod fs_transaction;
#[cfg(all(test, unix))]
#[path = "fs_helpers_windows_path.rs"]
mod fs_helpers_windows_path;
mod merge;
mod shell_profile;
mod tools;
pub(crate) use shell_profile::shell_quote;
pub(crate) use tools::{
cline_hooks_dir, omp_user_guard_path, pi_cli_user_guard_path, prime_agent_user_guard_path,
};
#[cfg(unix)]
mod zshenv;
pub use self::run_impl::run;
mod run_impl {
use super::fs_helpers;
use etcetera::BaseStrategy;
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy)]
enum ScopeSupport {
Both(Scope),
ProjectOnly(&'static str),
UserOnly(&'static str),
}
struct HostSpec {
name: &'static str,
scopes: ScopeSupport,
needs_python: bool,
shell_guard: bool,
setup: fn(&SetupOpts) -> Result<(), String>,
}
const HOSTS: &[HostSpec] = &[
HostSpec { name: "claude-code", scopes: ScopeSupport::Both(Scope::Project), needs_python: true, shell_guard: true, setup: setup_claude_code },
HostSpec { name: "cline", scopes: ScopeSupport::UserOnly("Cline's documented MCP registry and global hooks directory are user-global — omit --scope or use --scope user"), needs_python: true, shell_guard: false, setup: setup_cline },
HostSpec { name: "codex", scopes: ScopeSupport::UserOnly("Codex is always user-global — omit --scope or use --scope user"), needs_python: false, shell_guard: true, setup: setup_codex },
HostSpec { name: "copilot-cli", scopes: ScopeSupport::ProjectOnly("Copilot CLI loads hooks from the repo root — project-only. Omit --scope or use --scope project"), needs_python: true, shell_guard: true, setup: setup_copilot_cli },
HostSpec { name: "continue", scopes: ScopeSupport::ProjectOnly("Continue user config is shared YAML; Tirith safely owns only a workspace .continue/mcpServers block — omit --scope or use --scope project"), needs_python: false, shell_guard: false, setup: setup_continue },
HostSpec { name: "cursor", scopes: ScopeSupport::Both(Scope::Project), needs_python: true, shell_guard: true, setup: setup_cursor },
HostSpec { name: "fx", scopes: ScopeSupport::UserOnly("Vercel Labs fx loads native MCP servers from its trusted user profile only — omit --scope or use --scope user"), needs_python: false, shell_guard: false, setup: setup_fx },
HostSpec { name: "gemini-cli", scopes: ScopeSupport::Both(Scope::Project), needs_python: true, shell_guard: true, setup: setup_gemini_cli },
HostSpec { name: "grok-build", scopes: ScopeSupport::Both(Scope::Project), needs_python: cfg!(unix), shell_guard: false, setup: setup_grok_build },
HostSpec { name: "kiro", scopes: ScopeSupport::Both(Scope::Project), needs_python: true, shell_guard: true, setup: setup_kiro },
HostSpec { name: "omp", scopes: ScopeSupport::UserOnly("OMP project MCP setup is deferred because OMP merges settings from multiple project providers that can suppress it — omit --scope or use --scope user"), needs_python: false, shell_guard: false, setup: setup_omp },
HostSpec { name: "openclaw", scopes: ScopeSupport::Both(Scope::Project), needs_python: false, shell_guard: true, setup: setup_openclaw },
HostSpec { name: "opencode", scopes: ScopeSupport::Both(Scope::Project), needs_python: false, shell_guard: false, setup: setup_opencode },
HostSpec { name: "openhands", scopes: ScopeSupport::Both(Scope::User), needs_python: cfg!(unix), shell_guard: false, setup: setup_openhands },
HostSpec { name: "pi-cli", scopes: ScopeSupport::Both(Scope::Project), needs_python: false, shell_guard: true, setup: setup_pi_cli },
HostSpec { name: "prime-agent", scopes: ScopeSupport::UserOnly("Prime Agent executes generic MCP servers from user settings only — omit --scope or use --scope user"), needs_python: false, shell_guard: false, setup: setup_prime_agent },
HostSpec { name: "roo-code", scopes: ScopeSupport::ProjectOnly("Roo Code's global MCP path is editor-managed; Tirith safely writes the documented project .roo/mcp.json — omit --scope or use --scope project"), needs_python: false, shell_guard: false, setup: setup_roo_code },
HostSpec { name: "vscode", scopes: ScopeSupport::ProjectOnly("VS Code user settings use JSONC — run tirith setup vscode in your project directory instead, or configure manually"), needs_python: true, shell_guard: true, setup: setup_vscode },
HostSpec { name: "windsurf", scopes: ScopeSupport::UserOnly("Windsurf is always user-global — omit --scope or use --scope user"), needs_python: true, shell_guard: true, setup: setup_windsurf },
];
fn host_spec(tool: &str) -> Option<&'static HostSpec> {
HOSTS.iter().find(|spec| spec.name == tool)
}
const KNOWN_TOOLS: &[&str] = &[
"claude-code",
"cline",
"codex",
"copilot-cli",
"continue",
"cursor",
"fx",
"gemini-cli",
"grok-build",
"kiro",
"omp",
"openclaw",
"opencode",
"openhands",
"pi-cli",
"prime-agent",
"roo-code",
"vscode",
"windsurf",
];
fn unknown_tool_error(tool: &str) -> String {
let mut msg = format!(
"unknown tool '{tool}' — expected one of: {}",
KNOWN_TOOLS.join(", ")
);
if let Some(suggestion) = crate::cli::suggest_closest(tool, KNOWN_TOOLS, 3) {
msg.push_str(&format!("\n did you mean: tirith setup {suggestion}?"));
}
msg
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Project,
User,
}
pub struct SetupOpts {
pub scope: Scope,
pub with_mcp: bool,
pub install_zshenv: bool,
pub dry_run: bool,
pub force: bool,
pub tirith_bin: String,
pub python_bin: Option<String>,
pub update_configs: bool,
}
pub fn run(
tool: &str,
scope: Option<&str>,
with_mcp: bool,
install_zshenv: bool,
dry_run: bool,
force: bool,
update_configs: bool,
) -> i32 {
match run_inner(
tool,
scope,
with_mcp,
install_zshenv,
dry_run,
force,
update_configs,
) {
Ok(()) => {
if tirith_core::threatdb::ThreatDb::cached().is_none() {
eprintln!();
eprintln!(
"Optional: Run 'tirith threat-db update' to enable malicious package detection."
);
}
0
}
Err(msg) => {
eprintln!("tirith: {msg}");
1
}
}
}
fn run_inner(
tool: &str,
scope: Option<&str>,
with_mcp: bool,
install_zshenv: bool,
dry_run: bool,
force: bool,
update_configs: bool,
) -> Result<(), String> {
if with_mcp && tool != "claude-code" && tool != "gemini-cli" {
return Err(
"--with-mcp is only supported for claude-code and gemini-cli (other tools register MCP automatically or don't support it)"
.into(),
);
}
let spec = host_spec(tool).ok_or_else(|| unknown_tool_error(tool))?;
let scope = resolve_scope(tool, scope)?;
let tirith_bin = resolve_tirith_bin(dry_run)?;
let python_bin = if spec.needs_python {
let names: &[&str] = if cfg!(windows) && tool == "cline" {
&["python3", "python"]
} else {
&["python3"]
};
resolve_hook_dependency(names, "Python", dry_run)?
} else {
None
};
if install_zshenv && !spec.shell_guard {
return Err(format!(
"--install-zshenv is not part of the {tool} integration; use Tirith's shell setup separately when you need a shell-level guard"
));
}
if tool == "codex" {
check_binary_on_path("codex", dry_run)?;
}
if install_zshenv {
check_binary_on_path("zsh", dry_run)?;
}
let effective_force = force || update_configs;
let opts = SetupOpts {
scope,
with_mcp,
install_zshenv,
dry_run,
force: effective_force,
tirith_bin,
python_bin,
update_configs,
};
(spec.setup)(&opts)
}
pub(super) fn resolve_scope(tool: &str, scope: Option<&str>) -> Result<Scope, String> {
let spec = host_spec(tool).ok_or_else(|| unknown_tool_error(tool))?;
let (default, expected) = match spec.scopes {
ScopeSupport::Both(default) => (default, "'project' or 'user'"),
ScopeSupport::ProjectOnly(_) => (Scope::Project, "'project'"),
ScopeSupport::UserOnly(_) => (Scope::User, "'user'"),
};
let try_scope = match default {
Scope::Project => "project",
Scope::User => "user",
};
match (scope, spec.scopes) {
(None, _) => Ok(default),
(Some("project"), ScopeSupport::Both(_) | ScopeSupport::ProjectOnly(_)) => {
Ok(Scope::Project)
}
(Some("user"), ScopeSupport::Both(_) | ScopeSupport::UserOnly(_)) => Ok(Scope::User),
(Some("project"), ScopeSupport::UserOnly(reason))
| (Some("user"), ScopeSupport::ProjectOnly(reason)) => Err(reason.to_string()),
(Some(other), _) => Err(format!(
"invalid scope '{other}' — expected {expected}\n try: tirith setup {tool} --scope {try_scope}"
)),
}
}
fn resolve_tirith_bin(_dry_run: bool) -> Result<String, String> {
let current = tirith_core::trusted_child::TrustedExecutable::current().map_err(|error| {
format!(
"running tirith executable could not be validated for generated security configuration: {error}"
)
})?;
let stable_alias = stable_current_alias_on_path(¤t);
choose_generated_tirith_bin(Some(¤t), stable_alias.as_ref())
}
fn choose_generated_tirith_bin(
current: Option<&tirith_core::trusted_child::TrustedExecutable>,
stable_alias: Option<&tirith_core::trusted_child::TrustedExecutable>,
) -> Result<String, String> {
if let Some(current) = current {
current.revalidate().map_err(|error| {
format!("running tirith executable changed during setup validation: {error}")
})?;
if let Some(alias) = stable_alias {
let freshly_resolved_alias =
tirith_core::trusted_child::TrustedExecutable::from_absolute(
alias.invocation_path(),
&[],
)
.ok();
let alias_is_current = alias.invocation_path().is_absolute()
&& alias.path() == current.path()
&& alias.revalidate().is_ok();
let freshly_resolves_to_current = freshly_resolved_alias
.as_ref()
.is_some_and(|fresh| fresh.path() == current.path());
let current_identity_is_still_valid = current.revalidate().is_ok();
if alias_is_current
&& freshly_resolves_to_current
&& current_identity_is_still_valid
{
if let Some(alias) = alias.invocation_path().to_str() {
return Ok(alias.to_owned());
}
}
}
return path_to_utf8(current.path(), "running tirith executable");
}
Err(
"running tirith executable could not be validated for generated security configuration"
.into(),
)
}
fn stable_current_alias_on_path(
current: &tirith_core::trusted_child::TrustedExecutable,
) -> Option<tirith_core::trusted_child::TrustedExecutable> {
let path_value = std::env::var_os("PATH")?;
let candidate = tirith_core::trusted_child::TrustedExecutable::resolve_on_path(
"tirith",
&path_value,
&tirith_core::trusted_child::ambient_denied_roots(),
)
.ok()?;
let selected_parent = candidate.invocation_path().parent()?;
let current_dir = std::env::current_dir().ok()?;
let selected_path_entry = std::env::split_paths(&path_value).find(|directory| {
let absolute = if directory.is_absolute() {
directory.clone()
} else {
current_dir.join(directory)
};
absolute == selected_parent
})?;
(selected_path_entry.is_absolute()
&& candidate.invocation_path().is_absolute()
&& candidate.path() == current.path())
.then_some(candidate)
}
pub(super) fn path_to_utf8(path: &Path, role: &str) -> Result<String, String> {
path.to_str().map(str::to_owned).ok_or_else(|| {
format!(
"{role} path is not valid UTF-8 and cannot be persisted without changing its identity: {}",
path.display()
)
})
}
#[cfg(unix)]
pub(super) fn resolve_tirith_bin_for_zshenv(
tirith_bin: &str,
_dry_run: bool,
) -> Result<String, String> {
let current = tirith_core::trusted_child::TrustedExecutable::current().map_err(|error| {
format!("running tirith executable could not be validated for zshenv enforcement: {error}")
})?;
let stable_alias = if Path::new(tirith_bin).is_absolute() {
tirith_core::trusted_child::TrustedExecutable::from_absolute(
Path::new(tirith_bin),
&tirith_core::trusted_child::ambient_denied_roots(),
)
.ok()
} else {
None
};
choose_generated_tirith_bin(Some(¤t), stable_alias.as_ref())
}
#[cfg(all(test, unix))]
fn choose_zshenv_tirith_bin(
_path_candidate: Option<PathBuf>,
current_exe: Option<PathBuf>,
_tirith_bin: &str,
_dry_run: bool,
) -> Result<String, String> {
if let Some(exe) = current_exe {
return path_to_utf8(&exe, "running tirith executable for zshenv");
}
Err("running tirith executable could not be validated for zshenv enforcement".into())
}
#[cfg(unix)]
fn find_executable_on_path(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if !is_executable_file(&candidate) {
continue;
}
return candidate.canonicalize().ok().or(Some(candidate));
}
None
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
fn check_binary_on_path(name: &str, dry_run: bool) -> Result<(), String> {
let found = is_on_path(name);
if !found {
if dry_run {
eprintln!("tirith: WARNING: {name} not found on PATH");
Ok(())
} else {
Err(format!("{name} is required — install {name} and retry"))
}
} else {
Ok(())
}
}
fn resolve_hook_dependency(
names: &[&str],
label: &str,
dry_run: bool,
) -> Result<Option<String>, String> {
for name in names {
match tirith_core::trusted_child::resolve_ambient(name) {
Ok(executable) => {
executable.revalidate().map_err(|error| {
format!("validated {label} executable changed during setup: {error}")
})?;
return path_to_utf8(executable.invocation_path(), label).map(Some);
}
Err(tirith_core::trusted_child::TrustedExecutableError::NotFound(_)) => {}
Err(error) => {
return Err(format!(
"refusing untrusted {label} executable selected from PATH: {error}"
));
}
}
}
if dry_run {
eprintln!("tirith: WARNING: {label} not found on PATH");
Ok(None)
} else {
Err(format!("{label} is required — install {label} and retry"))
}
}
fn is_on_path(name: &str) -> bool {
#[cfg(unix)]
{
if name == "zsh" {
return super::zshenv::trusted_zsh_executable().is_ok();
}
find_executable_on_path(name).is_some()
}
#[cfg(not(unix))]
{
tirith_core::trusted_child::resolve_ambient(name).is_ok()
}
}
fn gateway_config_location() -> Result<(PathBuf, PathBuf), String> {
let base = etcetera::choose_base_strategy()
.map_err(|e| format!("could not determine config directory: {e}"))?;
let config_root = base.config_dir();
let gateway_path = config_root.join("tirith").join("gateway.yaml");
Ok((config_root, gateway_path))
}
pub(crate) fn codex_gateway_config_location() -> Result<(PathBuf, PathBuf), String> {
let (config_root, legacy_path) = gateway_config_location()?;
let digest = Sha256::digest(crate::assets::GATEWAY_YAML.as_bytes());
let mut digest_hex = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(&mut digest_hex, "{byte:02x}");
}
let gateway_path = legacy_path
.parent()
.ok_or_else(|| "gateway config path has no parent".to_string())?
.join(format!("gateway-sha256-{digest_hex}.yaml"));
Ok((config_root, gateway_path))
}
pub(crate) fn publish_codex_gateway_config(dry_run: bool) -> Result<PathBuf, String> {
let (config_root, gateway_path) = codex_gateway_config_location()?;
publish_codex_gateway_config_at(&config_root, &gateway_path, dry_run)
}
pub(super) fn publish_codex_gateway_config_at(
config_root: &Path,
gateway_path: &Path,
dry_run: bool,
) -> Result<PathBuf, String> {
path_to_utf8(gateway_path, "Codex gateway")?;
let content = crate::assets::GATEWAY_YAML;
let outcome = fs_helpers::transactional_update(
gateway_path,
config_root,
dry_run,
|snapshot| {
match snapshot.text(gateway_path)? {
Some(existing) if existing == content => {
Ok(fs_helpers::FileUpdate::unchanged())
}
Some(_) => Err(format!(
"content-addressed gateway generation {} exists with different bytes; refusing to overwrite it",
gateway_path.display()
)),
None => {
if dry_run {
eprintln!(
"[dry-run] would publish immutable gateway config {} ({} bytes)",
gateway_path.display(),
content.len()
);
}
Ok(fs_helpers::FileUpdate::write_text(content.to_string(), 0o644))
}
}
},
)?;
if !dry_run {
let published = fs_helpers::read_to_string_scoped(gateway_path, config_root)?;
if published.as_deref() != Some(content) {
return Err(format!(
"published gateway generation {} could not be verified byte-for-byte",
gateway_path.display()
));
}
}
if let Some(annotation) = outcome.completion_annotation() {
eprintln!(
"tirith: published immutable gateway config {}{annotation}",
gateway_path.display()
);
}
Ok(gateway_path.to_path_buf())
}
pub(crate) fn retire_codex_gateway_config(
previous: &Path,
current: &Path,
) -> Result<(), String> {
if previous == current {
return Ok(());
}
let (config_root, managed_current) = codex_gateway_config_location()?;
let managed_parent = managed_current
.parent()
.ok_or_else(|| "Codex gateway path has no parent".to_string())?;
if previous.parent() != Some(managed_parent) {
return Ok(());
}
fs_helpers::retire_codex_gateway_generation(previous, &config_root)
}
pub(crate) fn copy_gateway_config(force: bool, dry_run: bool) -> Result<PathBuf, String> {
let (config_root, gateway_path) = gateway_config_location()?;
let content = crate::assets::GATEWAY_YAML;
let outcome = fs_helpers::transactional_update(
&gateway_path,
&config_root,
dry_run,
|snapshot| {
if let Some(existing) = snapshot.text(&gateway_path)? {
if existing == content {
eprintln!(
"tirith: {} already configured, up to date",
gateway_path.display()
);
return Ok(fs_helpers::FileUpdate::unchanged());
}
if !force {
if dry_run {
eprintln!(
"[dry-run] would error: {} exists but content differs — use --force to update",
gateway_path.display()
);
return Ok(fs_helpers::FileUpdate::unchanged());
}
return Err(format!(
"{} exists but content differs — use --force to update",
gateway_path.display()
));
}
}
if dry_run {
eprintln!(
"[dry-run] would write {} ({} bytes)",
gateway_path.display(),
content.len()
);
}
Ok(fs_helpers::FileUpdate::write_text(
content.to_string(),
0o644,
))
},
)?;
if let Some(annotation) = outcome.completion_annotation() {
eprintln!("tirith: wrote {}{annotation}", gateway_path.display());
}
Ok(gateway_path)
}
pub(crate) fn setup_claude_code(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_claude_code(opts)
}
fn setup_codex(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_codex(opts)
}
fn setup_copilot_cli(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_copilot_cli(opts)
}
fn setup_cursor(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_cursor(opts)
}
fn setup_vscode(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_vscode(opts)
}
fn setup_gemini_cli(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_gemini_cli(opts)
}
fn setup_kiro(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_kiro(opts)
}
fn setup_openclaw(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_openclaw(opts)
}
fn setup_pi_cli(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_pi_cli(opts)
}
fn setup_prime_agent(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_prime_agent(opts)
}
fn setup_cline(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_cline(opts)
}
fn setup_continue(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_continue(opts)
}
fn setup_grok_build(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_grok_build(opts)
}
fn setup_omp(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_omp(opts)
}
fn setup_opencode(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_opencode(opts)
}
fn setup_fx(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_fx(opts)
}
fn setup_openhands(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_openhands(opts)
}
fn setup_roo_code(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_roo_code(opts)
}
fn setup_windsurf(opts: &SetupOpts) -> Result<(), String> {
super::tools::setup_windsurf(opts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_tools_is_exactly_the_host_table() {
let from_table: Vec<&str> = HOSTS.iter().map(|spec| spec.name).collect();
assert_eq!(
KNOWN_TOOLS,
&from_table[..],
"KNOWN_TOOLS must list the HOSTS rows in order; add new hosts to HOSTS"
);
}
#[test]
fn every_host_accepts_its_own_default_and_refuses_the_rest() {
for spec in HOSTS {
let default = resolve_scope(spec.name, None).unwrap();
match spec.scopes {
ScopeSupport::Both(expected) => {
assert_eq!(default, expected, "{}", spec.name);
assert_eq!(
resolve_scope(spec.name, Some("project")).unwrap(),
Scope::Project,
"{}",
spec.name
);
assert_eq!(
resolve_scope(spec.name, Some("user")).unwrap(),
Scope::User,
"{}",
spec.name
);
}
ScopeSupport::ProjectOnly(reason) => {
assert_eq!(default, Scope::Project, "{}", spec.name);
assert_eq!(
resolve_scope(spec.name, Some("user")).unwrap_err(),
reason,
"{}",
spec.name
);
}
ScopeSupport::UserOnly(reason) => {
assert_eq!(default, Scope::User, "{}", spec.name);
assert_eq!(
resolve_scope(spec.name, Some("project")).unwrap_err(),
reason,
"{}",
spec.name
);
}
}
let bogus = resolve_scope(spec.name, Some("global")).unwrap_err();
assert!(
bogus.contains("invalid scope 'global'"),
"{}: {bogus}",
spec.name
);
}
}
#[test]
fn openhands_accepts_both_scopes_with_a_user_default() {
assert_eq!(resolve_scope("openhands", None).unwrap(), Scope::User);
assert_eq!(
resolve_scope("openhands", Some("project")).unwrap(),
Scope::Project
);
assert_eq!(
resolve_scope("openhands", Some("user")).unwrap(),
Scope::User
);
}
#[test]
fn wrapper_hosts_require_python() {
for name in ["cline", "openhands"] {
let expected = name == "cline" || cfg!(unix);
assert_eq!(host_spec(name).unwrap().needs_python, expected, "{name}");
}
}
#[cfg(unix)]
#[test]
fn gateway_up_to_date_and_dry_run_refuse_symlinked_config_dir() {
use crate::cli::test_harness::{with_fake_env, EnvGuard};
with_fake_env(false, |home, _cwd| {
let config_root = home.join(".config");
std::fs::create_dir_all(&config_root).unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(
outside.path().join("gateway.yaml"),
crate::assets::GATEWAY_YAML,
)
.unwrap();
std::os::unix::fs::symlink(outside.path(), config_root.join("tirith")).unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_root);
for dry_run in [false, true] {
let result = copy_gateway_config(false, dry_run);
assert!(
result.is_err(),
"dry_run={dry_run} bypassed parent validation"
);
}
});
}
#[cfg(unix)]
fn write_executable(path: &Path, content: &str) {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
#[cfg(unix)]
#[test]
fn hook_dependency_refuses_the_first_repository_or_temp_path_hit() {
use crate::cli::test_harness::{with_fake_env, EnvGuard};
with_fake_env(true, |_home, cwd| {
let cwd = cwd.expect("isolated cwd");
let bin = cwd.join("bin");
let marker = cwd.join("python-was-executed");
let fake = bin.join("python3");
write_executable(&fake, &format!("#!/bin/sh\ntouch '{}'\n", marker.display()));
let _path = EnvGuard::set("PATH", &bin);
let error = resolve_hook_dependency(&["python3"], "Python", false)
.expect_err("a repository-selected interpreter must fail closed");
assert!(
error.contains("refusing untrusted Python executable"),
"{error}"
);
assert!(
!marker.exists(),
"dependency validation must never execute a PATH shadow"
);
});
}
#[cfg(unix)]
#[test]
fn generated_tirith_bin_is_canonical_absolute_current_identity() {
let dir = tempfile::tempdir().unwrap();
let current_path = dir.path().join("installed").join("tirith");
write_executable(¤t_path, "trusted current executable");
let current =
tirith_core::trusted_child::TrustedExecutable::from_absolute(¤t_path, &[])
.unwrap();
let resolved = choose_generated_tirith_bin(Some(¤t), None).unwrap();
assert_eq!(resolved, current.path().display().to_string());
assert!(Path::new(&resolved).is_absolute());
assert_ne!(resolved, "tirith");
}
#[cfg(target_os = "linux")]
#[test]
fn generated_tirith_bin_rejects_non_utf8_executable_identity() {
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().unwrap();
let name = std::ffi::OsString::from_vec(b"tirith-\xff".to_vec());
let current_path = dir.path().join(name);
write_executable(¤t_path, "trusted current executable");
let current =
tirith_core::trusted_child::TrustedExecutable::from_absolute(¤t_path, &[])
.unwrap();
let error = choose_generated_tirith_bin(Some(¤t), None).unwrap_err();
assert!(error.contains("not valid UTF-8"), "{error}");
assert!(error.contains("cannot be persisted"), "{error}");
}
#[cfg(unix)]
#[test]
fn generated_config_path_rejects_non_utf8_identity_without_filesystem_fixture() {
use std::os::unix::ffi::OsStringExt;
let path = PathBuf::from(std::ffi::OsString::from_vec(
b"/tmp/tirith-generated-\xff".to_vec(),
));
let error = path_to_utf8(&path, "running tirith executable").unwrap_err();
assert!(error.contains("not valid UTF-8"), "{error}");
assert!(error.contains("cannot be persisted"), "{error}");
}
#[cfg(unix)]
#[test]
fn generated_tirith_bin_prefers_validated_stable_alias() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let current_path = dir.path().join("installed").join("tirith");
let path_spelling = dir.path().join("path-bin").join("tirith");
write_executable(¤t_path, "trusted current executable");
std::fs::create_dir_all(path_spelling.parent().unwrap()).unwrap();
symlink(¤t_path, &path_spelling).unwrap();
let current =
tirith_core::trusted_child::TrustedExecutable::from_absolute(¤t_path, &[])
.unwrap();
let candidate =
tirith_core::trusted_child::TrustedExecutable::from_absolute(&path_spelling, &[])
.unwrap();
assert_eq!(candidate.path(), current.path());
let resolved = choose_generated_tirith_bin(Some(¤t), Some(&candidate)).unwrap();
assert_eq!(resolved, path_spelling.display().to_string());
assert!(Path::new(&resolved).is_absolute());
assert_ne!(resolved, "tirith");
}
#[cfg(unix)]
#[test]
fn generated_tirith_bin_stable_alias_survives_upgrade_retarget() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let v1 = dir.path().join("versions/v1/tirith");
let v2 = dir.path().join("versions/v2/tirith");
let stable = dir.path().join("bin/tirith");
write_executable(&v1, "trusted current executable v1");
write_executable(&v2, "trusted current executable v2");
std::fs::create_dir_all(stable.parent().unwrap()).unwrap();
symlink(&v1, &stable).unwrap();
let current_v1 =
tirith_core::trusted_child::TrustedExecutable::from_absolute(&v1, &[]).unwrap();
let alias_v1 =
tirith_core::trusted_child::TrustedExecutable::from_absolute(&stable, &[]).unwrap();
let persisted_v1 =
choose_generated_tirith_bin(Some(¤t_v1), Some(&alias_v1)).unwrap();
assert_eq!(persisted_v1, stable.display().to_string());
std::fs::remove_file(&stable).unwrap();
symlink(&v2, &stable).unwrap();
let current_v2 =
tirith_core::trusted_child::TrustedExecutable::from_absolute(&v2, &[]).unwrap();
let alias_v2 =
tirith_core::trusted_child::TrustedExecutable::from_absolute(&stable, &[]).unwrap();
let persisted_v2 =
choose_generated_tirith_bin(Some(¤t_v2), Some(&alias_v2)).unwrap();
assert_eq!(persisted_v2, persisted_v1);
assert_ne!(persisted_v2, current_v2.path().display().to_string());
}
#[cfg(target_os = "linux")]
#[test]
fn generated_tirith_bin_ignores_non_utf8_alias_when_canonical_is_utf8() {
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let current_path = dir.path().join("installed/tirith");
write_executable(¤t_path, "trusted current executable");
let alias_name = std::ffi::OsString::from_vec(b"tirith-\xff".to_vec());
let alias_path = dir.path().join("bin").join(alias_name);
std::fs::create_dir_all(alias_path.parent().unwrap()).unwrap();
symlink(¤t_path, &alias_path).unwrap();
let current =
tirith_core::trusted_child::TrustedExecutable::from_absolute(¤t_path, &[])
.unwrap();
let alias =
tirith_core::trusted_child::TrustedExecutable::from_absolute(&alias_path, &[])
.unwrap();
let resolved = choose_generated_tirith_bin(Some(¤t), Some(&alias)).unwrap();
assert_eq!(resolved, current.path().display().to_string());
}
#[test]
fn generated_tirith_bin_never_falls_back_to_bare_name() {
assert!(choose_generated_tirith_bin(None, None).is_err());
match resolve_tirith_bin(true) {
Ok(resolved) => assert!(
Path::new(&resolved).is_absolute(),
"resolved bin must be an absolute path: {resolved}"
),
Err(error) => assert!(
error.contains("could not be validated"),
"resolution may fail only by refusing validation: {error}"
),
}
}
#[cfg(windows)]
#[test]
fn binary_check_never_executes_current_directory_where_exe() {
use crate::cli::test_harness::{with_fake_env, EnvGuard};
with_fake_env(true, |_home, cwd| {
let cwd = cwd.unwrap();
std::fs::copy(std::env::current_exe().unwrap(), cwd.join("where.exe")).unwrap();
let _path = EnvGuard::set("PATH", Path::new(""));
assert!(!is_on_path("tirith-definitely-missing-tool.exe"));
});
}
#[cfg(unix)]
#[test]
fn zshenv_resolver_rejects_path_only_candidate_without_current_identity() {
let dir = tempfile::tempdir().unwrap();
let tirith = dir.path().join("tirith");
write_executable(&tirith, "");
assert!(choose_zshenv_tirith_bin(Some(tirith), None, "tirith", false).is_err());
}
#[cfg(unix)]
#[test]
fn zshenv_resolver_uses_current_exe_when_path_entry_is_script_wrapper() {
let dir = tempfile::tempdir().unwrap();
let wrapper = dir.path().join("tirith");
let native = dir.path().join("native").join("tirith");
write_executable(&wrapper, "#!/usr/bin/env node\n");
write_executable(&native, "");
let resolved =
choose_zshenv_tirith_bin(Some(wrapper), Some(native.clone()), "tirith", false)
.unwrap();
assert_eq!(resolved, native.display().to_string());
}
#[cfg(unix)]
#[test]
fn zshenv_resolver_rejects_non_utf8_executable_identity() {
use std::os::unix::ffi::OsStringExt;
let path = PathBuf::from(std::ffi::OsString::from_vec(
b"/tmp/tirith-zshenv-\xff".to_vec(),
));
let error = choose_zshenv_tirith_bin(None, Some(path), "tirith", false).unwrap_err();
assert!(error.contains("not valid UTF-8"), "{error}");
assert!(error.contains("cannot be persisted"), "{error}");
}
#[cfg(unix)]
#[test]
fn zshenv_resolver_ignores_poisoned_native_path_when_current_exe_is_known() {
let dir = tempfile::tempdir().unwrap();
let attacker = dir.path().join("repo-bin").join("tirith");
let current = dir.path().join("installed").join("tirith");
write_executable(&attacker, "native attacker placeholder");
write_executable(¤t, "trusted current executable placeholder");
let resolved = choose_zshenv_tirith_bin(
Some(attacker.clone()),
Some(current.clone()),
"tirith",
false,
)
.unwrap();
assert_eq!(resolved, current.display().to_string());
assert_ne!(resolved, attacker.display().to_string());
}
#[cfg(unix)]
#[test]
fn zshenv_resolver_rejects_unvalidated_absolute_fallback() {
assert!(choose_zshenv_tirith_bin(None, None, "/opt/custom/bin/tirith", false).is_err());
assert!(choose_zshenv_tirith_bin(None, None, "tirith", true).is_err());
}
#[cfg(unix)]
#[test]
fn find_executable_on_path_canonicalizes_symlink() {
use crate::cli::test_harness::{with_fake_env, EnvGuard};
use std::os::unix;
with_fake_env(false, |_home, _cwd| {
let target_dir = tempfile::tempdir().unwrap();
let link_dir = tempfile::tempdir().unwrap();
let real_tirith = target_dir.path().join("tirith");
write_executable(&real_tirith, "");
let symlink_tirith = link_dir.path().join("tirith");
unix::fs::symlink(&real_tirith, &symlink_tirith).unwrap();
let _path = EnvGuard::set("PATH", link_dir.path());
let found = find_executable_on_path("tirith")
.expect("symlink on PATH should be discoverable");
let expected = real_tirith
.canonicalize()
.expect("real tirith path canonicalizes");
assert_eq!(
found, expected,
"symlink must resolve to canonical real path"
);
});
}
#[test]
fn resolve_scope_rejects_user_for_copilot_cli() {
let result = resolve_scope("copilot-cli", Some("user"));
assert!(result.is_err(), "expected Err");
let msg = result.unwrap_err();
assert!(
msg.contains("project") && msg.contains("repo root"),
"expected project-only/repo-root message, got: {msg}"
);
}
#[test]
fn resolve_scope_accepts_project_for_copilot_cli() {
assert_eq!(
resolve_scope("copilot-cli", Some("project")).unwrap(),
Scope::Project
);
assert_eq!(resolve_scope("copilot-cli", None).unwrap(), Scope::Project);
}
#[test]
fn resolve_scope_accepts_both_for_kiro() {
assert_eq!(resolve_scope("kiro", None).unwrap(), Scope::Project);
assert_eq!(
resolve_scope("kiro", Some("project")).unwrap(),
Scope::Project
);
assert_eq!(resolve_scope("kiro", Some("user")).unwrap(), Scope::User);
}
#[test]
fn resolve_scope_pins_mcp_only_clients_to_documented_trust_scope() {
for tool in ["prime-agent", "fx", "cline", "omp"] {
assert_eq!(resolve_scope(tool, None).unwrap(), Scope::User, "{tool}");
assert!(resolve_scope(tool, Some("project")).is_err(), "{tool}");
}
assert_eq!(resolve_scope("openhands", None).unwrap(), Scope::User);
assert_eq!(
resolve_scope("openhands", Some("project")).unwrap(),
Scope::Project
);
for tool in ["continue", "roo-code"] {
assert_eq!(resolve_scope(tool, None).unwrap(), Scope::Project, "{tool}");
assert!(resolve_scope(tool, Some("user")).is_err(), "{tool}");
}
for tool in ["grok-build", "opencode"] {
assert_eq!(resolve_scope(tool, None).unwrap(), Scope::Project, "{tool}");
assert_eq!(
resolve_scope(tool, Some("user")).unwrap(),
Scope::User,
"{tool}"
);
}
}
}
}