agentsec_core/config.rs
1//! Edge-resolved configuration.
2//!
3//! [`Config`] is the single value that carries all environment-derived
4//! state through the crate. Library code **never** reads `std::env`
5//! directly — the binary's `main` resolves the env once into a [`Config`]
6//! and passes `&Config` down the call graph.
7//!
8//! ## Why this lives here
9//!
10//! Env reads are a side effect of the process boundary. Putting them in
11//! library functions makes the library:
12//!
13//! - **unsafe to test in parallel** (process env is global mutable state)
14//! - **dependent on global ordering** (which fn ran first wins)
15//! - **untestable without `unsafe { set_var }`** in tests
16//!
17//! By contrast, with `Config` resolved once at the outer rim and threaded
18//! through as `&Config`:
19//!
20//! - tests construct a `Config` with literal field values; no env writes
21//! - the only place that needs env-mock testing is
22//! [`Config::from_env_lookup`], which is a pure function over an
23//! injectable lookup closure
24//! - the binary's `main` is the *only* place that calls
25//! [`Config::from_env`] (= `std::env::var`)
26//!
27//! ## What env vars are read
28//!
29//! | Var | Field | Default |
30//! |----------------------|----------------------------------------|------------------------------------------|
31//! | `AGENTSEC_HOME` | [`Paths::home`] | `$HOME/.agentsec` ↓ |
32//! | `HOME` | [`Paths::user_home`] (and home fallback) | `.` |
33//! | `ANTHROPIC_API_KEY` | [`LlmConfig::api_key`] | `None` (semantic sanitize layer no-op) |
34//! | `AGENTSEC_LLM_MODEL` | [`LlmConfig::model`] | `claude-haiku-4-5-20251001` |
35
36use std::path::PathBuf;
37
38/// Default LLM model id used when `AGENTSEC_LLM_MODEL` is not set.
39pub const DEFAULT_LLM_MODEL: &str = "claude-haiku-4-5-20251001";
40
41/// All edge-resolved runtime configuration. Constructed once by the
42/// binary's `main` and passed by reference to library functions.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Config {
45 /// Filesystem paths AgentSec reads / writes.
46 pub paths: Paths,
47 /// LLM-backed sanitize layer configuration.
48 pub llm: LlmConfig,
49}
50
51/// Filesystem paths. See *crate root §Runtime data root* for the layout
52/// rules and *crate root §Read-only invariants* for what may / may not
53/// be written.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Paths {
56 /// AgentSec runtime data root (`$AGENTSEC_HOME` or `$HOME/.agentsec`).
57 pub home: PathBuf,
58 /// The user's home directory (`$HOME`). Used by
59 /// [`crate::scan::inventory`] to build absolute paths for the
60 /// `~/.claude/*` target list. Distinct from [`Self::home`].
61 pub user_home: PathBuf,
62}
63
64impl Paths {
65 /// `<home>/snapshots/` — scan snapshot files.
66 pub fn snapshots(&self) -> PathBuf {
67 self.home.join("snapshots")
68 }
69 /// `<home>/scans/` — SessionStart hook summary.
70 pub fn scans(&self) -> PathBuf {
71 self.home.join("scans")
72 }
73 /// `<home>/web_log/` — one JSON row per `web::fetch_and_sanitize`.
74 pub fn web_log(&self) -> PathBuf {
75 self.home.join("web_log")
76 }
77 /// `<home>/paste_log/` — one JSON row per `paste::detect`.
78 pub fn paste_log(&self) -> PathBuf {
79 self.home.join("paste_log")
80 }
81}
82
83/// Semantic-sanitize-layer configuration. See
84/// [`crate::web::sanitize::semantic_layer`] for fail-open semantics.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct LlmConfig {
87 /// Anthropic API key. `None` ⇒ the semantic layer is a no-op (the
88 /// regex layer alone protects).
89 pub api_key: Option<String>,
90 /// Anthropic model id for the semantic layer. Defaults to
91 /// [`DEFAULT_LLM_MODEL`].
92 pub model: String,
93}
94
95impl Config {
96 /// Build a [`Config`] by reading process environment variables.
97 ///
98 /// **This is the only function in the crate that touches
99 /// `std::env`.** Call it once at the binary's outer rim
100 /// (`fn main`) and thread `&Config` through everything else.
101 pub fn from_env() -> Self {
102 Self::from_env_lookup(|k| std::env::var(k).ok())
103 }
104
105 /// Build a [`Config`] from an arbitrary lookup closure.
106 ///
107 /// Pure function over the injected lookup — exposed so tests can pass
108 /// a `HashMap`-backed closure and verify the parse rules without
109 /// touching real process env.
110 ///
111 /// # Examples
112 ///
113 /// ```
114 /// use agentsec_core::config::{Config, DEFAULT_LLM_MODEL};
115 /// use std::collections::HashMap;
116 ///
117 /// let env: HashMap<&str, &str> = [
118 /// ("AGENTSEC_HOME", "/tmp/agentsec-test"),
119 /// ("HOME", "/home/test-user"),
120 /// ]
121 /// .into_iter()
122 /// .collect();
123 ///
124 /// let cfg = Config::from_env_lookup(|k| env.get(k).map(|s| s.to_string()));
125 /// assert_eq!(cfg.paths.home, std::path::PathBuf::from("/tmp/agentsec-test"));
126 /// assert_eq!(cfg.paths.user_home, std::path::PathBuf::from("/home/test-user"));
127 /// assert_eq!(cfg.llm.api_key, None);
128 /// assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
129 /// ```
130 pub fn from_env_lookup<F>(lookup: F) -> Self
131 where
132 F: Fn(&str) -> Option<String>,
133 {
134 let user_home = lookup("HOME").unwrap_or_else(|| ".".into());
135 let home = lookup("AGENTSEC_HOME").map_or_else(
136 || PathBuf::from(&user_home).join(".agentsec"),
137 PathBuf::from,
138 );
139 let user_home = PathBuf::from(user_home);
140
141 let api_key = lookup("ANTHROPIC_API_KEY").filter(|s| !s.is_empty());
142 let model = lookup("AGENTSEC_LLM_MODEL").unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string());
143
144 Self {
145 paths: Paths { home, user_home },
146 llm: LlmConfig { api_key, model },
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use std::collections::HashMap;
155
156 fn map_lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
157 let map: HashMap<&str, &str> = pairs.iter().copied().collect();
158 move |k| map.get(k).map(|s| (*s).to_string())
159 }
160
161 #[test]
162 fn empty_env_uses_all_defaults() {
163 let cfg = Config::from_env_lookup(|_| None);
164 // No HOME ⇒ user_home falls back to ".".
165 assert_eq!(cfg.paths.user_home, PathBuf::from("."));
166 // No AGENTSEC_HOME ⇒ home falls back to ./.agentsec.
167 assert_eq!(cfg.paths.home, PathBuf::from("./.agentsec"));
168 assert_eq!(cfg.llm.api_key, None);
169 assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
170 }
171
172 #[test]
173 fn home_only_derives_agentsec_home() {
174 let cfg = Config::from_env_lookup(map_lookup(&[("HOME", "/home/alice")]));
175 assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
176 assert_eq!(cfg.paths.home, PathBuf::from("/home/alice/.agentsec"));
177 }
178
179 #[test]
180 fn agentsec_home_overrides_default() {
181 let cfg = Config::from_env_lookup(map_lookup(&[
182 ("HOME", "/home/alice"),
183 ("AGENTSEC_HOME", "/var/lib/agentsec"),
184 ]));
185 assert_eq!(cfg.paths.home, PathBuf::from("/var/lib/agentsec"));
186 // user_home is untouched by AGENTSEC_HOME — they're distinct.
187 assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
188 }
189
190 #[test]
191 fn paths_methods_join_under_home() {
192 let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_HOME", "/tmp/x")]));
193 assert_eq!(cfg.paths.snapshots(), PathBuf::from("/tmp/x/snapshots"));
194 assert_eq!(cfg.paths.scans(), PathBuf::from("/tmp/x/scans"));
195 assert_eq!(cfg.paths.web_log(), PathBuf::from("/tmp/x/web_log"));
196 assert_eq!(cfg.paths.paste_log(), PathBuf::from("/tmp/x/paste_log"));
197 }
198
199 #[test]
200 fn empty_api_key_treated_as_absent() {
201 // Some shells export ANTHROPIC_API_KEY= when the secret isn't
202 // configured; an empty string must not flip the semantic layer on.
203 let cfg = Config::from_env_lookup(map_lookup(&[("ANTHROPIC_API_KEY", "")]));
204 assert_eq!(cfg.llm.api_key, None);
205 }
206
207 #[test]
208 fn api_key_and_model_are_picked_up() {
209 let cfg = Config::from_env_lookup(map_lookup(&[
210 ("ANTHROPIC_API_KEY", "sk-test"),
211 ("AGENTSEC_LLM_MODEL", "claude-sonnet-4-6"),
212 ]));
213 assert_eq!(cfg.llm.api_key, Some("sk-test".to_string()));
214 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
215 }
216}