use std::collections::HashSet;
use std::sync::{Mutex, OnceLock, PoisonError};
use super::v1::ConfigV1;
pub const ALLOW_REPO_LLM_ENV: &str = "BASEMIND_ALLOW_REPO_LLM";
pub const ALLOW_PRIVATE_HOSTS_ENV: &str = "BASEMIND_ALLOW_PRIVATE_HOSTS";
pub const ALLOW_FOLLOW_SYMLINKS_ENV: &str = "BASEMIND_ALLOW_FOLLOW_SYMLINKS";
#[derive(Debug, Clone, Copy, Default)]
pub struct Grants {
pub repo_llm: bool,
pub private_hosts: bool,
pub follow_symlinks: bool,
}
impl Grants {
pub fn from_env() -> Self {
Self {
repo_llm: env_truthy(ALLOW_REPO_LLM_ENV),
private_hosts: env_truthy(ALLOW_PRIVATE_HOSTS_ENV),
follow_symlinks: env_truthy(ALLOW_FOLLOW_SYMLINKS_ENV),
}
}
}
fn env_truthy(name: &str) -> bool {
std::env::var(name).is_ok_and(|v| {
let v = v.trim();
v.eq_ignore_ascii_case("1") || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes")
})
}
const PROVIDER_KEY_ENVS: &[(&str, &[&str])] = &[
("openai", &["OPENAI_API_KEY"]),
("anthropic", &["ANTHROPIC_API_KEY"]),
("azure", &["AZURE_API_KEY", "AZURE_OPENAI_API_KEY"]),
("gemini", &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
("google", &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
("groq", &["GROQ_API_KEY"]),
("mistral", &["MISTRAL_API_KEY"]),
("cohere", &["COHERE_API_KEY"]),
("deepseek", &["DEEPSEEK_API_KEY"]),
("xai", &["XAI_API_KEY"]),
("openrouter", &["OPENROUTER_API_KEY"]),
("together", &["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"]),
("fireworks", &["FIREWORKS_API_KEY"]),
("perplexity", &["PERPLEXITY_API_KEY"]),
];
const GENERIC_KEY_ENV: &str = "BASEMIND_LLM_API_KEY";
pub fn is_allowed_api_key_env(model: &str, name: &str) -> bool {
if name == GENERIC_KEY_ENV {
return true;
}
let provider = model.split('/').next().unwrap_or_default().to_ascii_lowercase();
PROVIDER_KEY_ENVS
.iter()
.any(|(p, names)| *p == provider && names.contains(&name))
}
pub fn sanitize_repo_config(config: &mut ConfigV1, grants: Grants) {
if !grants.repo_llm {
if let Some(url) = config.llm.base_url.take() {
warn_once(format!(
"ignoring [llm] base_url {url:?} from basemind.toml: a repository could point it at a host \
that collects your API key and document text; set {ALLOW_REPO_LLM_ENV}=1 to honour it"
));
}
if let super::ApiKey::Env { env } = &config.llm.api_key
&& !is_allowed_api_key_env(&config.llm.model, env)
{
warn_once(format!(
"ignoring [llm] api_key env reference {env:?} from basemind.toml: not a key for the provider \
of model {:?}; set {ALLOW_REPO_LLM_ENV}=1 to honour it",
config.llm.model
));
config.llm.api_key = super::ApiKey::Unset;
}
}
if config.scan.follow_symlinks && !grants.follow_symlinks {
config.scan.follow_symlinks = false;
warn_once(format!(
"ignoring [scan] follow_symlinks = true from basemind.toml: a tracked link could pull files outside \
the repository into the index; set {ALLOW_FOLLOW_SYMLINKS_ENV}=1 in the environment to honour it"
));
}
if config.crawl.allow_private_network && !grants.private_hosts {
config.crawl.allow_private_network = false;
warn_once(format!(
"ignoring [crawl] allow_private_network = true from basemind.toml: set {ALLOW_PRIVATE_HOSTS_ENV}=1 \
in the environment to allow crawling private and loopback hosts"
));
}
}
const INERT_FIELDS: &[&str] = &[
"watch.live_l2",
"cache.file_map_lru",
"mcp.transport",
"memory.enabled",
"memory.scope_strategy",
"memory.default_visibility",
"comms.enabled",
"comms.idle_timeout_secs",
"comms.max_messages_per_room",
"comms.retention_secs",
"comms.max_rooms",
"comms.workspace_root",
"shells.keep_on_exit",
"documents.ocr.backend",
"documents.ocr.languages",
"documents.language.preferred_languages",
];
pub fn inert_fields_set(config: &ConfigV1) -> Vec<&'static str> {
let (Ok(set), Ok(default)) = (
serde_json::to_value(config),
serde_json::to_value(ConfigV1::with_defaults()),
) else {
return Vec::new();
};
INERT_FIELDS
.iter()
.copied()
.filter(|path| {
let pointer = format!("/{}", path.replace('.', "/"));
set.pointer(&pointer) != default.pointer(&pointer)
})
.collect()
}
pub fn warn_inert_fields(config: &ConfigV1) {
for path in inert_fields_set(config) {
warn_once(format!(
"basemind.toml sets [{path}], which is reserved and currently has no effect"
));
}
}
pub(crate) fn warn_once(message: String) {
static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let mut seen = SEEN
.get_or_init(Default::default)
.lock()
.unwrap_or_else(PoisonError::into_inner);
if seen.insert(message.clone()) {
tracing::warn!("{message}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ApiKey;
fn repo_config(extra: &str) -> ConfigV1 {
crate::config::parse_str(&format!("\"$schema\" = \"v1\"\n{extra}")).expect("parse")
}
#[test]
fn repo_base_url_is_dropped_without_the_grant() {
let mut cfg = repo_config("[llm]\nmodel = \"openai/gpt-4o\"\nbase_url = \"https://evil.example\"\n");
sanitize_repo_config(&mut cfg, Grants::default());
assert_eq!(cfg.llm.base_url, None);
assert_eq!(cfg.llm.model, "openai/gpt-4o");
}
#[test]
fn repo_base_url_is_kept_with_the_grant() {
let mut cfg = repo_config("[llm]\nmodel = \"openai/gpt-4o\"\nbase_url = \"http://localhost:8000\"\n");
sanitize_repo_config(
&mut cfg,
Grants {
repo_llm: true,
..Grants::default()
},
);
assert_eq!(cfg.llm.base_url.as_deref(), Some("http://localhost:8000"));
}
#[test]
fn arbitrary_env_key_reference_is_dropped_without_the_grant() {
let mut cfg = repo_config("[llm]\nmodel = \"openai/gpt-4o\"\napi_key = { env = \"AWS_SECRET_ACCESS_KEY\" }\n");
sanitize_repo_config(&mut cfg, Grants::default());
assert_eq!(cfg.llm.api_key, ApiKey::Unset);
}
#[test]
fn provider_matched_env_key_reference_survives() {
let mut cfg = repo_config("[llm]\nmodel = \"openai/gpt-4o\"\napi_key = { env = \"OPENAI_API_KEY\" }\n");
sanitize_repo_config(&mut cfg, Grants::default());
assert_eq!(
cfg.llm.api_key,
ApiKey::Env {
env: "OPENAI_API_KEY".into()
}
);
}
#[test]
fn env_key_for_another_provider_is_dropped() {
assert!(!is_allowed_api_key_env("openai/gpt-4o", "ANTHROPIC_API_KEY"));
assert!(!is_allowed_api_key_env("", "OPENAI_API_KEY"));
assert!(is_allowed_api_key_env("anthropic/claude", "ANTHROPIC_API_KEY"));
assert!(is_allowed_api_key_env("whatever/x", GENERIC_KEY_ENV));
}
#[test]
fn any_env_key_reference_is_kept_with_the_grant() {
let mut cfg = repo_config("[llm]\nmodel = \"openai/gpt-4o\"\napi_key = { env = \"CORP_LLM_TOKEN\" }\n");
sanitize_repo_config(
&mut cfg,
Grants {
repo_llm: true,
..Grants::default()
},
);
assert_eq!(
cfg.llm.api_key,
ApiKey::Env {
env: "CORP_LLM_TOKEN".into()
}
);
}
#[test]
fn allow_private_network_needs_the_operator_grant() {
let mut cfg = repo_config("[crawl]\nallow_private_network = true\n");
sanitize_repo_config(&mut cfg, Grants::default());
assert!(!cfg.crawl.allow_private_network);
let mut cfg = repo_config("[crawl]\nallow_private_network = true\n");
sanitize_repo_config(
&mut cfg,
Grants {
private_hosts: true,
..Grants::default()
},
);
assert!(cfg.crawl.allow_private_network);
}
#[test]
fn follow_symlinks_needs_the_operator_grant() {
let mut cfg = repo_config("[scan]\nfollow_symlinks = true\n");
sanitize_repo_config(&mut cfg, Grants::default());
assert!(!cfg.scan.follow_symlinks);
let mut cfg = repo_config("[scan]\nfollow_symlinks = true\n");
sanitize_repo_config(
&mut cfg,
Grants {
follow_symlinks: true,
..Grants::default()
},
);
assert!(cfg.scan.follow_symlinks);
}
#[test]
fn inert_fields_are_reported_only_when_non_default() {
assert!(inert_fields_set(&ConfigV1::with_defaults()).is_empty());
let cfg = repo_config(
"[watch]\nlive_l2 = true\n[comms]\nmax_rooms = 3\n[documents.language]\npreferred_languages = [\"fra\"]\n",
);
assert_eq!(
inert_fields_set(&cfg),
vec![
"watch.live_l2",
"comms.max_rooms",
"documents.language.preferred_languages"
]
);
}
}