car-external-agents 0.49.0

Detection of installed agentic CLIs (Claude Code, Codex, Gemini) for the Common Agent Runtime.
//! Cycle guard for CAR ↔ external-agent invocation.
//!
//! ## The loop
//!
//! `car-external-agents` spawns installed CLIs (`claude`, `codex`, `gemini`)
//! as agents. Once CAR is *also* reachable from inside those tools — as a
//! Claude Code plugin, a Codex skill, an MCP server — the arrow points both
//! ways:
//!
//! ```text
//! Claude Code → car do → car-external-agents spawns `claude` → CAR plugin
//!   → car do → spawns `claude` → …
//! ```
//!
//! Nothing already in the system stops this. Claude Code's own subagent depth
//! limit counts *its* nesting and cannot see across the CAR boundary; CAR's
//! `pin_env_var` pins which binary an adapter resolves to but says nothing
//! about whether it should run. Each hop costs a process, a model session, and
//! real money, and the failure looks like a hang rather than an error.
//!
//! ## The guard
//!
//! One environment variable, `CAR_INVOKED_BY`, carrying the **ancestry**: a
//! comma-separated list of the adapter ids already in the chain above this
//! process. CAR refuses to spawn an adapter that is already in that list, and
//! stamps the child's environment with the list plus the adapter it just
//! spawned.
//!
//! Ancestry rather than a bare depth counter because a counter cannot tell a
//! cycle from a chain. `claude → codex → gemini` is three legitimate hops;
//! `claude → codex → claude` is a loop that a counter set to 3 would happily
//! allow. Ancestry catches the indirect cycle (A→B→A) that a single
//! "who invoked me" value would miss, and its length doubles as the depth, so
//! there is one variable rather than two that can disagree.
//!
//! ## For plugin authors
//!
//! A host that launches CAR from inside an agent **must** set
//! `CAR_INVOKED_BY` to its own adapter id — `claude-code`, `codex`, or
//! `gemini`. That is the seed; CAR maintains it from there. Without it the
//! first hop is invisible and the guard only catches the second one.
//!
//! ## Hosts that do not launch CAR: the daemon case
//!
//! The seed above is a *process* environment, which works because the host
//! launches CAR. A long-lived daemon is launched by the supervisor or the
//! login item and serves many callers, so its own `CAR_INVOKED_BY` says
//! nothing about who is calling right now — every request would look like hop
//! zero, which is the "first hop is invisible" case above, permanently.
//!
//! So a caller that reaches CAR over a connection rather than by spawning it
//! names itself **per call** instead: [`seed_ancestry_in`] merges the caller's
//! own adapter id with the chain the daemon read once at startup, giving an
//! ancestry the *request* carries. The daemon records that on the run and hands
//! it back, so a chain stays visible across a hop that no process env spans.
//!
//! It records rather than refuses: nothing on that surface spawns an adapter,
//! so there is nothing for the decision to be about. The two env-reading
//! wrappers ([`spawn_block_reason`], [`child_ancestry`]) are unchanged and
//! remain the right entry points for anything CAR does spawn — that is where a
//! refusal happens, including for an agent further down a chain that this seed
//! is what made visible.
//!
//! ## Bounds
//!
//! This is a cooperative guard, not a sandbox. It relies on the child
//! inheriting the environment CAR sets. A tool that scrubs its environment, or
//! a chain that leaves and re-enters through something that does not propagate
//! it, defeats it. It is the right mechanism for the cost-and-hang problem it
//! addresses and should not be described as a security boundary.

/// Ancestry of external-agent adapters above this process, comma-separated.
pub const ANCESTRY_ENV: &str = "CAR_INVOKED_BY";

/// Overrides [`DEFAULT_MAX_CHAIN`].
pub const MAX_CHAIN_ENV: &str = "CAR_AGENT_MAX_CHAIN";

/// How many external-agent hops a chain may contain before CAR refuses.
///
/// Three permits genuine delegation (`claude → codex → gemini`) while bounding
/// the blast radius of a chain that fans out without repeating an adapter.
pub const DEFAULT_MAX_CHAIN: usize = 3;

/// Parse the current ancestry from the environment.
///
/// Public so a long-lived process (the daemon) can read it **once**, where it
/// is spawned, and carry it as an explicit value from then on — see
/// [`seed_ancestry_in`]. A server that re-reads the environment on every
/// request is deciding from process-global state that nothing in the request
/// controls, which is untestable as well as misleading.
pub fn ancestry() -> Vec<String> {
    parse_ancestry(std::env::var(ANCESTRY_ENV).ok().as_deref())
}

/// Parse an ancestry string. Split out so the logic is testable without
/// mutating process-global environment state, which races across threads in a
/// test binary.
///
/// Private: a per-call caller wants [`seed_ancestry_in`], which merges rather
/// than parses. Widen this when something outside actually holds a raw
/// comma-separated chain.
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)
}

/// Why spawning `adapter_id` from this process would be refused, or `None`.
///
/// Callers that surface a list of available agents should consult this so a
/// blocked adapter is never offered, rather than offered and then refused.
pub fn spawn_block_reason(adapter_id: &str) -> Option<String> {
    block_reason_in(adapter_id, &ancestry(), max_chain())
}

/// The decision, over explicit inputs.
///
/// Private, deliberately. The obvious next step is to let the daemon judge a
/// request's seeded ancestry with this — but nothing on that surface *spawns*
/// an adapter, so there is no `adapter_id` to ask about. Make it `pub` when a
/// caller holding a request-borne ancestry actually needs to spawn something;
/// exporting it before then advertises an integration that does not exist.
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
}

/// The ancestry value a child spawned for `adapter_id` should carry.
pub fn child_ancestry(adapter_id: &str) -> String {
    let mut chain = ancestry();
    chain.push(adapter_id.trim().to_ascii_lowercase());
    chain.join(",")
}

/// The ancestry a per-call caller should be judged against: whatever this
/// process already carries, plus the adapter the caller named itself as.
///
/// `invoked_by` is the caller's own adapter id — the same value a spawning
/// host would have put in `$CAR_INVOKED_BY`, except that it arrives with the
/// request because the daemon was not spawned by the caller. `None` means the
/// caller named nothing, which yields the process ancestry unchanged: the
/// guard cannot invent a hop it was never told about, and pretending otherwise
/// would refuse legitimate first calls.
///
/// Lowercased and de-duplicated, so a host that both sets the env var and
/// sends `invoked_by` is one hop rather than two — otherwise the same caller
/// would consume the chain budget twice and eventually be refused for a depth
/// it never had.
pub fn seed_ancestry(invoked_by: Option<&str>) -> Vec<String> {
    seed_ancestry_in(&ancestry(), invoked_by)
}

/// The seed, over an explicit base chain.
///
/// Public for the same reason [`block_reason_in`] is, plus one more: a process
/// that serves many callers reads its own ancestry once at startup and passes
/// it here, so the seed for a given request is a function of that request and
/// that stored base — not of whatever the environment happens to say now.
/// [`seed_ancestry`] is this function over [`ancestry`].
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
}

/// Stamp a child command with the ancestry it should inherit.
///
/// Applied at the invocation sites rather than inside `base_command`, which is
/// shared with the `--version` detection probes — a probe is not a hop, and
/// counting it as one would exhaust the chain budget without any agent having
/// run.
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() {
        // Claude Code → car do → claude. The case this guard exists for.
        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() {
        // A→B→A. A single "who invoked me" value would miss this; ancestry
        // does not.
        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() {
        // The reason this is ancestry and not a counter: three distinct hops
        // are legitimate delegation, not a loop.
        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() {
        // A host setting CAR_INVOKED_BY=Claude-Code must not slip past the
        // guard on capitalization.
        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());
    }

    /// Serializes the env-reading tests. `set_var` is process-global and the
    /// test binary is threaded, so without this they race each other.
    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() {
        // Proves the decision actually reads CAR_INVOKED_BY, not just that the
        // pure logic is right — the wiring is where this would silently fail.
        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());
        // And the child carries the chain forward, which is what makes the
        // guard work more than one level down.
        assert_eq!(child_ancestry("codex"), "claude-code,codex");
    }

    #[test]
    fn a_per_call_caller_names_itself_and_is_then_refused_a_cycle() {
        // The daemon case: the process env says nothing, so the ancestry comes
        // from the request. Without the seed every hop looks like hop zero.
        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() {
        // A host that sets the env var AND sends invoked_by must not burn two
        // hops of the chain budget for one caller.
        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"])
        );
        // An empty or whitespace-only id names nothing rather than an empty hop.
        assert_eq!(seed_ancestry(Some("  ")), chain(&["claude-code"]));
    }

    #[test]
    fn the_seed_over_an_explicit_base_reads_no_environment() {
        // The daemon's path: `ancestry()` once at startup, this per request.
        // Deliberately no ENV_LOCK and no EnvGuard — if this test ever needs
        // one, the function has stopped being a function of its arguments.
        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() {
        // An operator hitting this needs to know what looped and what to do.
        let why = block_reason_in("codex", &chain(&["codex"]), 3).unwrap();
        assert!(why.contains("codex"), "{why}");
        assert!(why.contains(ANCESTRY_ENV), "{why}");
    }
}