pub const ANCESTRY_ENV: &str = "CAR_INVOKED_BY";
pub const MAX_CHAIN_ENV: &str = "CAR_AGENT_MAX_CHAIN";
pub const DEFAULT_MAX_CHAIN: usize = 3;
pub fn ancestry() -> Vec<String> {
parse_ancestry(std::env::var(ANCESTRY_ENV).ok().as_deref())
}
fn parse_ancestry(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_ascii_lowercase)
.collect()
}
fn max_chain() -> usize {
std::env::var(MAX_CHAIN_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_MAX_CHAIN)
}
pub fn spawn_block_reason(adapter_id: &str) -> Option<String> {
block_reason_in(adapter_id, &ancestry(), max_chain())
}
fn block_reason_in(adapter_id: &str, ancestry: &[String], max: usize) -> Option<String> {
let target = adapter_id.trim().to_ascii_lowercase();
if ancestry.contains(&target) {
return Some(format!(
"refusing to spawn `{adapter_id}`: it is already in this invocation chain \
({}). Spawning it again would loop CAR and the agent into each other. \
If this chain is intentional, clear or edit ${ANCESTRY_ENV}.",
ancestry.join(" → ")
));
}
if ancestry.len() >= max {
return Some(format!(
"refusing to spawn `{adapter_id}`: invocation chain is already {} deep ({}), \
at the limit of {max}. Raise ${MAX_CHAIN_ENV} if this depth is intended.",
ancestry.len(),
ancestry.join(" → ")
));
}
None
}
pub fn child_ancestry(adapter_id: &str) -> String {
let mut chain = ancestry();
chain.push(adapter_id.trim().to_ascii_lowercase());
chain.join(",")
}
pub fn seed_ancestry(invoked_by: Option<&str>) -> Vec<String> {
seed_ancestry_in(&ancestry(), invoked_by)
}
pub fn seed_ancestry_in(base: &[String], invoked_by: Option<&str>) -> Vec<String> {
let mut chain = base.to_vec();
if let Some(id) = invoked_by {
let id = id.trim().to_ascii_lowercase();
if !id.is_empty() && !chain.contains(&id) {
chain.push(id);
}
}
chain
}
pub fn stamp_child(cmd: &mut tokio::process::Command, adapter_id: &str) {
cmd.env(ANCESTRY_ENV, child_ancestry(adapter_id));
}
#[cfg(test)]
mod tests {
use super::*;
fn chain(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_fresh_process_may_spawn_anything() {
assert!(block_reason_in("claude-code", &[], DEFAULT_MAX_CHAIN).is_none());
}
#[test]
fn the_direct_cycle_is_refused() {
let why = block_reason_in("claude-code", &chain(&["claude-code"]), DEFAULT_MAX_CHAIN)
.expect("should refuse");
assert!(why.contains("already in this invocation chain"), "{why}");
}
#[test]
fn the_indirect_cycle_is_refused_too() {
assert!(block_reason_in(
"claude-code",
&chain(&["claude-code", "codex"]),
DEFAULT_MAX_CHAIN
)
.is_some());
}
#[test]
fn a_genuine_chain_of_distinct_agents_is_allowed() {
assert!(block_reason_in(
"gemini",
&chain(&["claude-code", "codex"]),
DEFAULT_MAX_CHAIN
)
.is_none());
}
#[test]
fn the_chain_limit_stops_a_non_repeating_fan_out() {
let why = block_reason_in("gemini", &chain(&["claude-code", "codex", "other"]), 3)
.expect("should refuse");
assert!(why.contains("at the limit of 3"), "{why}");
}
#[test]
fn ancestry_parsing_tolerates_whitespace_case_and_empties() {
assert_eq!(
parse_ancestry(Some(" Claude-Code , ,codex ")),
chain(&["claude-code", "codex"])
);
assert!(parse_ancestry(None).is_empty());
assert!(parse_ancestry(Some("")).is_empty());
assert!(parse_ancestry(Some(",,,")).is_empty());
}
#[test]
fn adapter_ids_match_case_insensitively() {
assert!(block_reason_in("claude-code", &chain(&["CLAUDE-CODE"]), 3).is_none());
assert!(block_reason_in("claude-code", &parse_ancestry(Some("CLAUDE-CODE")), 3).is_some());
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard(Option<String>);
impl EnvGuard {
fn set(value: Option<&str>) -> Self {
let prev = std::env::var(ANCESTRY_ENV).ok();
match value {
Some(v) => std::env::set_var(ANCESTRY_ENV, v),
None => std::env::remove_var(ANCESTRY_ENV),
}
Self(prev)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.0 {
Some(v) => std::env::set_var(ANCESTRY_ENV, v),
None => std::env::remove_var(ANCESTRY_ENV),
}
}
}
#[test]
fn the_env_plumbing_is_wired_end_to_end() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g = EnvGuard::set(None);
assert!(spawn_block_reason("claude-code").is_none());
assert_eq!(child_ancestry("claude-code"), "claude-code");
let _g = EnvGuard::set(Some("claude-code"));
assert!(spawn_block_reason("claude-code").is_some());
assert!(spawn_block_reason("codex").is_none());
assert_eq!(child_ancestry("codex"), "claude-code,codex");
}
#[test]
fn a_per_call_caller_names_itself_and_is_then_refused_a_cycle() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g = EnvGuard::set(None);
assert!(seed_ancestry(None).is_empty());
let seeded = seed_ancestry(Some("Claude-Code"));
assert_eq!(seeded, chain(&["claude-code"]));
assert!(block_reason_in("claude-code", &seeded, DEFAULT_MAX_CHAIN).is_some());
assert!(block_reason_in("codex", &seeded, DEFAULT_MAX_CHAIN).is_none());
}
#[test]
fn naming_yourself_twice_is_still_one_hop() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _g = EnvGuard::set(Some("claude-code"));
assert_eq!(seed_ancestry(Some("claude-code")), chain(&["claude-code"]));
assert_eq!(
seed_ancestry(Some("codex")),
chain(&["claude-code", "codex"])
);
assert_eq!(seed_ancestry(Some(" ")), chain(&["claude-code"]));
}
#[test]
fn the_seed_over_an_explicit_base_reads_no_environment() {
assert!(seed_ancestry_in(&[], None).is_empty());
assert_eq!(
seed_ancestry_in(&[], Some("Claude-Code")),
chain(&["claude-code"])
);
assert_eq!(
seed_ancestry_in(&chain(&["claude-code"]), Some("claude-code")),
chain(&["claude-code"]),
"a host that both sets the env var and names itself is one hop"
);
assert_eq!(
seed_ancestry_in(&chain(&["claude-code"]), Some("codex")),
chain(&["claude-code", "codex"])
);
assert_eq!(
seed_ancestry_in(&chain(&["claude-code"]), Some(" ")),
chain(&["claude-code"]),
"an empty id names nothing rather than an empty hop"
);
}
#[test]
fn the_refusal_names_the_chain_and_the_escape_hatch() {
let why = block_reason_in("codex", &chain(&["codex"]), 3).unwrap();
assert!(why.contains("codex"), "{why}");
assert!(why.contains(ANCESTRY_ENV), "{why}");
}
}