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//! | `AGENTSEC_PASTE_THRESHOLD` | [`PasteConfig::threshold_bytes`] | `1024` |
36//! | `AGENTSEC_WEB_TIMEOUT` | [`WebConfig::timeout_secs`] | `10` |
37
38use std::path::PathBuf;
39
40/// Default LLM model id used when `AGENTSEC_LLM_MODEL` is not set.
41pub const DEFAULT_LLM_MODEL: &str = "claude-haiku-4-5-20251001";
42
43/// Default paste detection threshold (bytes). Content smaller than this limit
44/// is still inspected; the threshold governs log verbosity in future versions.
45/// Currently kept as a configuration hook for downstream callers.
46pub const DEFAULT_PASTE_THRESHOLD_BYTES: u64 = 1024;
47
48/// Default web-fetch timeout in seconds used when `AGENTSEC_WEB_TIMEOUT` is
49/// not set. Distinct from the old hard-coded `DEFAULT_TIMEOUT_SECS` constant
50/// in `web/fetch.rs` (which was 15 s); the new config-driven default is 10 s.
51pub const DEFAULT_WEB_TIMEOUT_SECS: u64 = 10;
52
53/// All edge-resolved runtime configuration. Constructed once by the
54/// binary's `main` and passed by reference to library functions.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Config {
57 /// Filesystem paths AgentSec reads / writes.
58 pub paths: Paths,
59 /// LLM-backed sanitize layer configuration.
60 pub llm: LlmConfig,
61 /// Paste detection configuration.
62 pub paste: PasteConfig,
63 /// Web fetch configuration.
64 pub web: WebConfig,
65 /// Absolute path of the `.env` file that the binary actually loaded
66 /// at startup (via `AGENTSEC_DOTENV` or `dotenvy::dotenv()`), if any.
67 /// `None` ⇒ no `.env` was loaded. Used by [`crate::diagnostics`] to
68 /// trace env-var provenance.
69 pub dotenv_path: Option<PathBuf>,
70}
71
72/// Paste detection configuration.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PasteConfig {
75 /// Paste content size threshold in bytes. Currently used as a
76 /// configuration hook; future versions may skip lighter inspection
77 /// paths for very small inputs below this threshold.
78 /// Default: [`DEFAULT_PASTE_THRESHOLD_BYTES`] (1024).
79 pub threshold_bytes: u64,
80}
81
82impl Default for PasteConfig {
83 fn default() -> Self {
84 Self {
85 threshold_bytes: DEFAULT_PASTE_THRESHOLD_BYTES,
86 }
87 }
88}
89
90/// Web fetch configuration.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct WebConfig {
93 /// HTTP request timeout in seconds.
94 /// Default: [`DEFAULT_WEB_TIMEOUT_SECS`] (10).
95 pub timeout_secs: u64,
96}
97
98impl Default for WebConfig {
99 fn default() -> Self {
100 Self {
101 timeout_secs: DEFAULT_WEB_TIMEOUT_SECS,
102 }
103 }
104}
105
106/// Filesystem paths. See *crate root §Runtime data root* for the layout
107/// rules and *crate root §Read-only invariants* for what may / may not
108/// be written.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Paths {
111 /// AgentSec runtime data root (`$AGENTSEC_HOME` or `$HOME/.agentsec`).
112 pub home: PathBuf,
113 /// The user's home directory (`$HOME`). Used by
114 /// [`crate::scan::inventory`] to build absolute paths for the
115 /// `~/.claude/*` target list. Distinct from [`Self::home`].
116 pub user_home: PathBuf,
117}
118
119impl Paths {
120 /// `<home>/snapshots/` — scan snapshot files.
121 pub fn snapshots(&self) -> PathBuf {
122 self.home.join("snapshots")
123 }
124 /// `<home>/scans/` — SessionStart hook summary.
125 pub fn scans(&self) -> PathBuf {
126 self.home.join("scans")
127 }
128 /// `<home>/web_log/` — one JSON row per `web::fetch_and_sanitize`.
129 pub fn web_log(&self) -> PathBuf {
130 self.home.join("web_log")
131 }
132 /// `<home>/paste_log/` — one JSON row per `paste::detect`.
133 pub fn paste_log(&self) -> PathBuf {
134 self.home.join("paste_log")
135 }
136}
137
138/// Semantic-sanitize-layer configuration. See
139/// [`crate::web::sanitize::semantic_layer`] for fail-open semantics.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct LlmConfig {
142 /// Anthropic API key. `None` ⇒ the semantic layer is a no-op (the
143 /// regex layer alone protects).
144 pub api_key: Option<String>,
145 /// Anthropic model id for the semantic layer. Defaults to
146 /// [`DEFAULT_LLM_MODEL`].
147 pub model: String,
148}
149
150impl Config {
151 /// Build a [`Config`] by reading process environment variables.
152 ///
153 /// **This is the only function in the crate that touches
154 /// `std::env`.** Call it once at the binary's outer rim
155 /// (`fn main`) and thread `&Config` through everything else.
156 ///
157 /// `dotenv_path` carries the absolute path of the `.env` file the
158 /// binary loaded at startup (so diagnostics can trace env
159 /// provenance); pass `None` if no `.env` was loaded.
160 pub fn from_env(dotenv_path: Option<PathBuf>) -> Self {
161 let mut cfg = Self::from_env_lookup(|k| std::env::var(k).ok());
162 cfg.dotenv_path = dotenv_path;
163 cfg
164 }
165
166 /// Build a [`Config`] from an arbitrary lookup closure.
167 ///
168 /// Pure function over the injected lookup — exposed so tests can pass
169 /// a `HashMap`-backed closure and verify the parse rules without
170 /// touching real process env.
171 ///
172 /// # Examples
173 ///
174 /// ```
175 /// use agentsec_core::config::{Config, DEFAULT_LLM_MODEL};
176 /// use std::collections::HashMap;
177 ///
178 /// let env: HashMap<&str, &str> = [
179 /// ("AGENTSEC_HOME", "/tmp/agentsec-test"),
180 /// ("HOME", "/home/test-user"),
181 /// ]
182 /// .into_iter()
183 /// .collect();
184 ///
185 /// let cfg = Config::from_env_lookup(|k| env.get(k).map(|s| s.to_string()));
186 /// assert_eq!(cfg.paths.home, std::path::PathBuf::from("/tmp/agentsec-test"));
187 /// assert_eq!(cfg.paths.user_home, std::path::PathBuf::from("/home/test-user"));
188 /// assert_eq!(cfg.llm.api_key, None);
189 /// assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
190 /// ```
191 pub fn from_env_lookup<F>(lookup: F) -> Self
192 where
193 F: Fn(&str) -> Option<String>,
194 {
195 let user_home = lookup("HOME").unwrap_or_else(|| ".".into());
196 let home = lookup("AGENTSEC_HOME").map_or_else(
197 || PathBuf::from(&user_home).join(".agentsec"),
198 PathBuf::from,
199 );
200 let user_home = PathBuf::from(user_home);
201
202 let api_key = lookup("ANTHROPIC_API_KEY").filter(|s| !s.is_empty());
203 let model = lookup("AGENTSEC_LLM_MODEL").unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string());
204
205 let paste_threshold = lookup("AGENTSEC_PASTE_THRESHOLD")
206 .and_then(|s| s.parse::<u64>().ok())
207 .unwrap_or(DEFAULT_PASTE_THRESHOLD_BYTES);
208
209 let web_timeout = lookup("AGENTSEC_WEB_TIMEOUT")
210 .and_then(|s| s.parse::<u64>().ok())
211 .unwrap_or(DEFAULT_WEB_TIMEOUT_SECS);
212
213 Self {
214 paths: Paths { home, user_home },
215 llm: LlmConfig { api_key, model },
216 paste: PasteConfig {
217 threshold_bytes: paste_threshold,
218 },
219 web: WebConfig {
220 timeout_secs: web_timeout,
221 },
222 dotenv_path: None,
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use std::collections::HashMap;
231
232 fn map_lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
233 let map: HashMap<&str, &str> = pairs.iter().copied().collect();
234 move |k| map.get(k).map(|s| (*s).to_string())
235 }
236
237 #[test]
238 fn empty_env_uses_all_defaults() {
239 let cfg = Config::from_env_lookup(|_| None);
240 // No HOME ⇒ user_home falls back to ".".
241 assert_eq!(cfg.paths.user_home, PathBuf::from("."));
242 // No AGENTSEC_HOME ⇒ home falls back to ./.agentsec.
243 assert_eq!(cfg.paths.home, PathBuf::from("./.agentsec"));
244 assert_eq!(cfg.llm.api_key, None);
245 assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
246 assert_eq!(cfg.paste.threshold_bytes, DEFAULT_PASTE_THRESHOLD_BYTES);
247 assert_eq!(cfg.web.timeout_secs, DEFAULT_WEB_TIMEOUT_SECS);
248 }
249
250 #[test]
251 fn home_only_derives_agentsec_home() {
252 let cfg = Config::from_env_lookup(map_lookup(&[("HOME", "/home/alice")]));
253 assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
254 assert_eq!(cfg.paths.home, PathBuf::from("/home/alice/.agentsec"));
255 }
256
257 #[test]
258 fn agentsec_home_overrides_default() {
259 let cfg = Config::from_env_lookup(map_lookup(&[
260 ("HOME", "/home/alice"),
261 ("AGENTSEC_HOME", "/var/lib/agentsec"),
262 ]));
263 assert_eq!(cfg.paths.home, PathBuf::from("/var/lib/agentsec"));
264 // user_home is untouched by AGENTSEC_HOME — they're distinct.
265 assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
266 }
267
268 #[test]
269 fn paths_methods_join_under_home() {
270 let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_HOME", "/tmp/x")]));
271 assert_eq!(cfg.paths.snapshots(), PathBuf::from("/tmp/x/snapshots"));
272 assert_eq!(cfg.paths.scans(), PathBuf::from("/tmp/x/scans"));
273 assert_eq!(cfg.paths.web_log(), PathBuf::from("/tmp/x/web_log"));
274 assert_eq!(cfg.paths.paste_log(), PathBuf::from("/tmp/x/paste_log"));
275 }
276
277 #[test]
278 fn paste_threshold_override_via_env() {
279 let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_PASTE_THRESHOLD", "4096")]));
280 assert_eq!(cfg.paste.threshold_bytes, 4096);
281 }
282
283 #[test]
284 fn paste_threshold_invalid_env_uses_default() {
285 let cfg =
286 Config::from_env_lookup(map_lookup(&[("AGENTSEC_PASTE_THRESHOLD", "not-a-number")]));
287 assert_eq!(cfg.paste.threshold_bytes, DEFAULT_PASTE_THRESHOLD_BYTES);
288 }
289
290 #[test]
291 fn web_timeout_override_via_env() {
292 let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_WEB_TIMEOUT", "30")]));
293 assert_eq!(cfg.web.timeout_secs, 30);
294 }
295
296 #[test]
297 fn web_timeout_invalid_env_uses_default() {
298 let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_WEB_TIMEOUT", "bad")]));
299 assert_eq!(cfg.web.timeout_secs, DEFAULT_WEB_TIMEOUT_SECS);
300 }
301
302 #[test]
303 fn empty_api_key_treated_as_absent() {
304 // Some shells export ANTHROPIC_API_KEY= when the secret isn't
305 // configured; an empty string must not flip the semantic layer on.
306 let cfg = Config::from_env_lookup(map_lookup(&[("ANTHROPIC_API_KEY", "")]));
307 assert_eq!(cfg.llm.api_key, None);
308 }
309
310 #[test]
311 fn api_key_and_model_are_picked_up() {
312 let cfg = Config::from_env_lookup(map_lookup(&[
313 ("ANTHROPIC_API_KEY", "sk-test"),
314 ("AGENTSEC_LLM_MODEL", "claude-sonnet-4-6"),
315 ]));
316 assert_eq!(cfg.llm.api_key, Some("sk-test".to_string()));
317 assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
318 }
319}