mod antigravity;
mod api;
mod augment;
mod claude;
mod claude_cowork;
mod codex;
mod cursor;
mod cursor_cli;
mod devin;
mod gemini;
mod github_copilot;
mod opencode;
mod replit;
mod v0;
pub use api::{AiAgent, AiEnvironment, AiNetworkPolicy};
use std::collections::HashMap;
use std::env;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::OnceLock;
pub fn is_ai_agent() -> bool {
get_environment().is_some()
}
static AGENT: OnceLock<AiAgent> = OnceLock::new();
pub fn detect_agent() -> AiAgent {
*AGENT.get_or_init(|| {
let vars = env::vars().collect::<HashMap<_, _>>();
detect_agent_from_vars(&vars)
})
}
fn detect_agent_from_vars(vars: &HashMap<String, String>) -> AiAgent {
let get = |key: &str| vars.get(key).map(|v| v.as_str()).filter(|v| !v.is_empty());
let any = |keys: &[&str]| keys.iter().any(|key| get(key).is_some());
if get("CURSOR_TRACE_ID").is_some() {
return AiAgent::Cursor;
}
if get("CURSOR_AGENT").is_some() || get("CURSOR_EXTENSION_HOST_ROLE") == Some("agent-exec") {
return AiAgent::CursorCli;
}
if get("GEMINI_CLI").is_some() {
return AiAgent::Gemini;
}
if any(&["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]) {
return AiAgent::Codex;
}
if get("ANTIGRAVITY_AGENT").is_some() {
return AiAgent::Antigravity;
}
if get("AUGMENT_AGENT").is_some() {
return AiAgent::Augment;
}
if any(&["OPENCODE_CLIENT", "OPENCODE"]) {
return AiAgent::OpenCode;
}
if any(&["CLAUDECODE", "CLAUDE_CODE"]) {
return if get("CLAUDE_CODE_IS_COWORK").is_some() {
AiAgent::ClaudeCowork
} else {
AiAgent::Claude
};
}
if get("REPL_ID").is_some() {
return AiAgent::Replit;
}
if any(&[
"COPILOT_CLI",
"COPILOT_MODEL",
"COPILOT_ALLOW_ALL",
"COPILOT_GITHUB_TOKEN",
]) {
return AiAgent::GithubCopilot;
}
if let Some(value) = get("AI_AGENT") {
let agent = classify_self_id(value);
if !matches!(agent, AiAgent::Unknown) {
return agent;
}
}
if PathBuf::from("/opt/.devin").exists() {
return AiAgent::Devin;
}
AiAgent::Unknown
}
fn classify_self_id(value: &str) -> AiAgent {
let name = value.trim().to_ascii_lowercase();
if name == "v0" {
AiAgent::V0
} else if name == "github-copilot" || name == "github-copilot-cli" {
AiAgent::GithubCopilot
} else if name.starts_with("claude") {
AiAgent::Claude
} else if name.starts_with("cowork") {
AiAgent::ClaudeCowork
} else if name.starts_with("codex") {
AiAgent::Codex
} else if name.starts_with("cursor") {
AiAgent::Cursor
} else if name.starts_with("gemini") {
AiAgent::Gemini
} else if name.starts_with("antigravity") {
AiAgent::Antigravity
} else if name.starts_with("augment") {
AiAgent::Augment
} else if name.starts_with("opencode") {
AiAgent::OpenCode
} else if name.starts_with("devin") {
AiAgent::Devin
} else if name.starts_with("replit") {
AiAgent::Replit
} else {
AiAgent::Unknown
}
}
static NETWORK_POLICY: OnceLock<AiNetworkPolicy> = OnceLock::new();
pub fn detect_network_policy(agent: AiAgent) -> AiNetworkPolicy {
*NETWORK_POLICY.get_or_init(|| {
let vars = env::vars().collect::<HashMap<_, _>>();
detect_network_policy_from_vars(agent, &vars)
})
}
fn detect_network_policy_from_vars(
agent: AiAgent,
vars: &HashMap<String, String>,
) -> AiNetworkPolicy {
let get = |key: &str| vars.get(key).map(|v| v.as_str()).filter(|v| !v.is_empty());
match agent {
AiAgent::Codex
if get("CODEX_SANDBOX_NETWORK_DISABLED").is_some_and(|v| v == "1" || v == "true") =>
{
return AiNetworkPolicy::Disabled;
}
AiAgent::GithubCopilot
if vars.iter().any(|(key, value)| {
key.starts_with("COPILOT_AGENT_FIREWALL_") && !value.is_empty()
}) =>
{
return if get("COPILOT_AGENT_FIREWALL_ENABLED")
.is_some_and(|v| v == "false" || v == "0")
{
AiNetworkPolicy::Open
} else {
AiNetworkPolicy::Filtered
};
}
_ => {}
}
for key in [
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
] {
if get(key)
.and_then(proxy_host)
.is_some_and(is_internal_proxy_host)
{
return AiNetworkPolicy::Filtered;
}
}
AiNetworkPolicy::Unknown
}
fn proxy_host(value: &str) -> Option<&str> {
let rest = value.split_once("://").map_or(value, |(_, rest)| rest);
let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest);
let rest = match rest.find(['/', '?']) {
Some(index) => &rest[..index],
None => rest,
};
if let Some(v6) = rest.strip_prefix('[') {
return v6.split(']').next();
}
if rest.parse::<IpAddr>().is_ok() {
return Some(rest);
}
let host = rest.rsplit_once(':').map_or(rest, |(host, _)| host);
(!host.is_empty() && !host.contains(':')).then_some(host)
}
fn is_internal_proxy_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
if let Ok(ip) = host.parse::<IpAddr>() {
return match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
IpAddr::V6(v6) => v6.is_loopback(),
};
}
!host.is_empty()
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn get_environment() -> Option<AiEnvironment> {
let environment = match detect_agent() {
AiAgent::Antigravity => antigravity::create_environment(),
AiAgent::Augment => augment::create_environment(),
AiAgent::Claude => claude::create_environment(),
AiAgent::ClaudeCowork => claude_cowork::create_environment(),
AiAgent::Codex => codex::create_environment(),
AiAgent::Cursor => cursor::create_environment(),
AiAgent::CursorCli => cursor_cli::create_environment(),
AiAgent::Devin => devin::create_environment(),
AiAgent::Gemini => gemini::create_environment(),
AiAgent::GithubCopilot => github_copilot::create_environment(),
AiAgent::OpenCode => opencode::create_environment(),
AiAgent::Replit => replit::create_environment(),
AiAgent::V0 => v0::create_environment(),
AiAgent::Unknown => {
return api::self_id().map(|id| AiEnvironment {
agent: AiAgent::Unknown,
env_prefix: None,
id: Some(id),
network: detect_network_policy(AiAgent::Unknown),
sandboxed: false,
session_id: None,
});
}
};
Some(environment)
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(list: &[(&str, &str)]) -> HashMap<String, String> {
list.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect()
}
#[test]
fn detects_claude_both_spellings() {
assert_eq!(
detect_agent_from_vars(&vars(&[("CLAUDECODE", "1")])),
AiAgent::Claude
);
assert_eq!(
detect_agent_from_vars(&vars(&[("CLAUDE_CODE", "1")])),
AiAgent::Claude
);
}
#[test]
fn cowork_mode_distinguished_from_claude() {
let env = vars(&[("CLAUDECODE", "1"), ("CLAUDE_CODE_IS_COWORK", "1")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::ClaudeCowork);
}
#[test]
fn detects_codex_via_sandbox_or_thread() {
assert_eq!(
detect_agent_from_vars(&vars(&[("CODEX_SANDBOX", "seatbelt")])),
AiAgent::Codex
);
assert_eq!(
detect_agent_from_vars(&vars(&[("CODEX_THREAD_ID", "th_123")])),
AiAgent::Codex
);
}
#[test]
fn detects_cursor_editor_and_cli() {
assert_eq!(
detect_agent_from_vars(&vars(&[("CURSOR_TRACE_ID", "abc")])),
AiAgent::Cursor
);
assert_eq!(
detect_agent_from_vars(&vars(&[("CURSOR_AGENT", "1")])),
AiAgent::CursorCli
);
}
#[test]
fn cursor_cli_via_extension_host_role() {
assert_eq!(
detect_agent_from_vars(&vars(&[("CURSOR_EXTENSION_HOST_ROLE", "agent-exec")])),
AiAgent::CursorCli
);
assert_eq!(
detect_agent_from_vars(&vars(&[("CURSOR_EXTENSION_HOST_ROLE", "extension")])),
AiAgent::Unknown
);
}
#[test]
fn detects_gemini() {
let env = vars(&[("GEMINI_CLI", "1")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Gemini);
}
#[test]
fn detects_antigravity() {
let env = vars(&[("ANTIGRAVITY_AGENT", "1")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Antigravity);
}
#[test]
fn detects_github_copilot() {
assert_eq!(
detect_agent_from_vars(&vars(&[("COPILOT_MODEL", "gpt-5")])),
AiAgent::GithubCopilot
);
assert_eq!(
detect_agent_from_vars(&vars(&[("COPILOT_GITHUB_TOKEN", "x")])),
AiAgent::GithubCopilot
);
}
#[test]
fn detects_opencode_either_marker() {
assert_eq!(
detect_agent_from_vars(&vars(&[("OPENCODE", "1")])),
AiAgent::OpenCode
);
assert_eq!(
detect_agent_from_vars(&vars(&[("OPENCODE_CLIENT", "x")])),
AiAgent::OpenCode
);
}
#[test]
fn classifies_self_id_when_no_marker() {
assert_eq!(
detect_agent_from_vars(&vars(&[("AI_AGENT", "claude-code_2-1-170_agent")])),
AiAgent::Claude
);
assert_eq!(
detect_agent_from_vars(&vars(&[("AI_AGENT", "v0")])),
AiAgent::V0
);
}
#[test]
fn boolean_marker_and_self_id_agree() {
let env = vars(&[
("CLAUDECODE", "1"),
("AI_AGENT", "claude-code_2-1-170_agent"),
]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Claude);
}
#[test]
fn cursor_editor_outranks_claude() {
let env = vars(&[("CURSOR_TRACE_ID", "abc"), ("CLAUDECODE", "1")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Cursor);
}
#[test]
fn unrecognized_self_id_is_unknown() {
let env = vars(&[("AI_AGENT", "some-new-tool")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Unknown);
}
#[test]
fn empty_values_are_ignored() {
let env = vars(&[("CLAUDECODE", "")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Unknown);
}
#[test]
fn no_agent() {
let env = vars(&[("PATH", "/usr/bin")]);
assert_eq!(detect_agent_from_vars(&env), AiAgent::Unknown);
}
#[test]
fn network_disabled_for_codex_sandbox() {
assert_eq!(
detect_network_policy_from_vars(
AiAgent::Codex,
&vars(&[("CODEX_SANDBOX_NETWORK_DISABLED", "1")])
),
AiNetworkPolicy::Disabled
);
assert_eq!(
detect_network_policy_from_vars(
AiAgent::Codex,
&vars(&[("CODEX_SANDBOX_NETWORK_DISABLED", "0")])
),
AiNetworkPolicy::Unknown
);
}
#[test]
fn network_filtered_for_copilot_firewall() {
assert_eq!(
detect_network_policy_from_vars(
AiAgent::GithubCopilot,
&vars(&[("COPILOT_AGENT_FIREWALL_ENABLE_RULESET_ALLOW_LIST", "true")])
),
AiNetworkPolicy::Filtered
);
assert_eq!(
detect_network_policy_from_vars(
AiAgent::GithubCopilot,
&vars(&[("COPILOT_AGENT_FIREWALL_ALLOW_LIST_ADDITIONS", "example.com")])
),
AiNetworkPolicy::Filtered
);
}
#[test]
fn network_open_when_copilot_firewall_disabled() {
let env = vars(&[
("COPILOT_AGENT_FIREWALL_ENABLED", "false"),
("COPILOT_AGENT_FIREWALL_LOG_FILE", "/tmp/fw.jsonl"),
]);
assert_eq!(
detect_network_policy_from_vars(AiAgent::GithubCopilot, &env),
AiNetworkPolicy::Open
);
}
#[test]
fn network_unknown_for_copilot_cli_alone() {
let env = vars(&[("COPILOT_CLI", "1")]);
assert_eq!(
detect_network_policy_from_vars(AiAgent::GithubCopilot, &env),
AiNetworkPolicy::Unknown
);
}
#[test]
fn network_vendor_signals_only_apply_to_their_own_agent() {
let copilot = vars(&[("COPILOT_AGENT_FIREWALL_ENABLE_RULESET_ALLOW_LIST", "true")]);
assert_eq!(
detect_network_policy_from_vars(AiAgent::Cursor, &copilot),
AiNetworkPolicy::Unknown
);
let codex = vars(&[("CODEX_SANDBOX_NETWORK_DISABLED", "1")]);
assert_eq!(
detect_network_policy_from_vars(AiAgent::Claude, &codex),
AiNetworkPolicy::Unknown
);
}
#[test]
fn network_filtered_for_internal_proxies() {
for value in [
"http://127.0.0.1:3129",
"http://localhost:8080",
"http://[::1]:8080",
"socks5://user:pass@10.0.0.2:1080",
"http://proxy:8080",
] {
assert_eq!(
detect_network_policy_from_vars(AiAgent::Claude, &vars(&[("HTTPS_PROXY", value)])),
AiNetworkPolicy::Filtered,
"value {value:?}"
);
}
assert_eq!(
detect_network_policy_from_vars(
AiAgent::Gemini,
&vars(&[("http_proxy", "http://127.0.0.1:3129")])
),
AiNetworkPolicy::Filtered
);
}
#[test]
fn network_unknown_for_external_proxies() {
for value in [
"http://proxy.corp.example.com:8080",
"https://8.8.8.8:443",
"",
] {
assert_eq!(
detect_network_policy_from_vars(AiAgent::Claude, &vars(&[("HTTPS_PROXY", value)])),
AiNetworkPolicy::Unknown,
"value {value:?}"
);
}
}
#[test]
fn network_disabled_outranks_other_signals() {
let env = vars(&[
("CODEX_SANDBOX_NETWORK_DISABLED", "1"),
("HTTPS_PROXY", "http://127.0.0.1:3129"),
]);
assert_eq!(
detect_network_policy_from_vars(AiAgent::Codex, &env),
AiNetworkPolicy::Disabled
);
}
#[test]
fn no_network_signals() {
let env = vars(&[("PATH", "/usr/bin")]);
assert_eq!(
detect_network_policy_from_vars(AiAgent::Claude, &env),
AiNetworkPolicy::Unknown
);
}
}