Skip to main content

runtimo_core/
config.rs

1//! Persistent configuration for Runtimo.
2//!
3//! Reads/writes a TOML config file at `~/.config/runtimo/config.toml`.
4//! Allowed path prefixes are merged from three sources (lowest to highest priority):
5//! 1. Built-in defaults (`/tmp`, `/var/tmp`, `/home`)
6//! 2. `RUNTIMO_ALLOWED_PATHS` env var (colon-separated)
7//! 3. Config file `allowed_paths` array
8//! 4. Context-specific prefixes (programmatic override)
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14/// Built-in default allowed prefixes.
15const DEFAULT_PREFIXES: &[&str] = &["/tmp", "/var/tmp", "/home"];
16
17/// Runtimo persistent configuration.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19#[allow(clippy::exhaustive_structs)]
20pub struct RuntimoConfig {
21    /// Additional allowed path prefixes (merged with defaults + env var).
22    #[serde(default)]
23    pub allowed_paths: Vec<String>,
24
25    /// Design Assurance Level (A-E) for the llmosafe cognitive pipeline.
26    ///
27    /// When set, overrides the `RUNTIMO_DAL` env var. Controls how strictly
28    /// the cognitive safety pipeline gates execution:
29    /// - A: No override (strictest)
30    /// - B: Halt → Escalate
31    /// - C: Halt/Escalate → Warn
32    /// - D: Halt/Escalate/Exit → Warn (cap at Warn)
33    /// - E: All decisions → Proceed (permissive)
34    #[serde(default)]
35    pub dal: Option<String>,
36
37    /// Additional dangerous command patterns for ShellExec blocklist.
38    ///
39    /// Each entry is a substring that triggers rejection. Merged with the
40    /// built-in blocklist. Example: `["curl", "wget"]` blocks those commands
41    /// even when `RUNTIMO_ENABLE_NETWORK=1`.
42    #[serde(default)]
43    pub blocklist_overrides: Vec<String>,
44
45    /// Per-capability timeout defaults (seconds).
46    ///
47    /// Maps capability name to default timeout. Overrides the built-in
48    /// defaults (30s for most, 300s for ShellExec). Individual executions
49    /// can still override via `timeout_secs` in args.
50    #[serde(default)]
51    pub capability_timeouts: HashMap<String, u64>,
52}
53
54impl RuntimoConfig {
55    /// Returns the config file path following XDG spec.
56    ///
57    /// Uses `XDG_CONFIG_HOME` if set, otherwise `~/.config/runtimo/config.toml`.
58    ///
59    /// Falls back to `/tmp/runtimo/config.toml` with a stderr warning when
60    /// neither `XDG_CONFIG_HOME` nor `HOME` is set. Configuration in `/tmp`
61    /// is not persistent across reboots.
62    pub fn config_path() -> PathBuf {
63        let base = std::env::var("XDG_CONFIG_HOME")
64            .ok()
65            .map(PathBuf::from)
66            .or_else(|| {
67                std::env::var("HOME")
68                    .ok()
69                    .map(|h| PathBuf::from(h).join(".config"))
70            });
71        if let Some(dir) = base {
72            dir.join("runtimo/config.toml")
73        } else {
74            eprintln!(
75                "[runtimo] Warning: XDG_CONFIG_HOME and HOME unset — using /tmp/runtimo \
76                 (config will not survive reboot)"
77            );
78            PathBuf::from("/tmp/runtimo/config.toml")
79        }
80    }
81
82    /// Loads config from disk, returning defaults if the file doesn't exist or is invalid.
83    ///
84    /// Logs a warning to stderr when the file exists but cannot be read or parsed.
85    /// Prefer [`Self::load_result`] for new code — it propagates errors so callers can
86    /// distinguish "file doesn't exist" from "file is corrupt."
87    #[must_use]
88    pub fn load() -> Self {
89        match Self::load_result() {
90            Ok(config) => config,
91            Err(e) => {
92                eprintln!("[runtimo] Config load failed (using defaults): {}", e);
93                Self::default()
94            }
95        }
96    }
97
98    /// Loads config from disk, propagating read and parse errors.
99    ///
100    /// # Input
101    ///
102    /// Reads from the path returned by [`Self::config_path`] if it exists.
103    ///
104    /// # Output
105    ///
106    /// `Ok(RuntimoConfig)` — Successfully deserialized config, or default if file doesn't exist.
107    ///
108    /// # Errors
109    ///
110    /// Returns `Err(String)` when the config file:
111    /// - Exists but cannot be opened (permission denied, filesystem error)
112    /// - Can be opened but contains invalid TOML syntax
113    /// - Contains TOML that deserializes to a different type (schema mismatch)
114    ///
115    /// Returns `Ok(Self::default())` when:
116    /// - The config file does not exist (first run / clean install)
117    /// - The config file is empty (no config needed)
118    pub fn load_result() -> Result<Self, String> {
119        let path = Self::config_path();
120        if path.exists() {
121            let content = std::fs::read_to_string(&path)
122                .map_err(|e| format!("Cannot read config file '{}': {}", path.display(), e))?;
123            toml::from_str(&content)
124                .map_err(|e| format!("Cannot parse config file '{}': {}", path.display(), e))
125        } else {
126            Ok(Self::default())
127        }
128    }
129
130    /// Saves config to disk, creating parent directories as needed.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if parent directories cannot be created or if the config
135    /// file cannot be serialized/written to disk.
136    pub fn save(&self) -> Result<(), String> {
137        let path = Self::config_path();
138        if let Some(parent) = path.parent() {
139            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
140        }
141        let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
142        std::fs::write(&path, content).map_err(|e| e.to_string())?;
143        Ok(())
144    }
145
146    /// Returns the resolved Design Assurance Level.
147    ///
148    /// Priority (highest to lowest):
149    /// 1. `RUNTIMO_DAL` env var
150    /// 2. Config file `dal` field
151    /// 3. Default: `A`
152    #[must_use]
153    pub fn get_dal() -> String {
154        // Env var takes precedence
155        if let Ok(env_dal) = std::env::var("RUNTIMO_DAL") {
156            return env_dal.to_uppercase();
157        }
158        // Config file
159        let config = Self::load();
160        config.dal.unwrap_or_else(|| "A".to_string())
161    }
162
163    /// Returns the merged blocklist overrides from config.
164    ///
165    /// These are additional substrings that trigger rejection in ShellExec,
166    /// merged on top of the built-in blocklist.
167    #[must_use]
168    pub fn get_blocklist_overrides() -> Vec<String> {
169        let config = Self::load();
170        config.blocklist_overrides
171    }
172
173    /// Returns the default timeout for a capability, or the fallback.
174    ///
175    /// Checks config file `capability_timeouts` map, falls back to the
176    /// provided default if no override is configured.
177    #[must_use]
178    pub fn get_capability_timeout(capability: &str, fallback: u64) -> u64 {
179        let config = Self::load();
180        config
181            .capability_timeouts
182            .get(capability)
183            .copied()
184            .unwrap_or(fallback)
185    }
186
187    /// Returns merged prefixes: defaults + env var + config file.
188    ///
189    /// Priority (lowest to highest):
190    /// 1. Built-in defaults
191    /// 2. `RUNTIMO_ALLOWED_PATHS` env var
192    /// 3. Config file `allowed_paths`
193    ///
194    /// Empty strings are filtered out to prevent matching everything
195    /// via `format!("{}/", "")` which produces `"/"` (N-014).
196    #[must_use]
197    pub fn get_allowed_prefixes() -> Vec<String> {
198        let mut prefixes: Vec<String> = DEFAULT_PREFIXES.iter().map(|s| s.to_string()).collect();
199
200        // Env var (colon-separated)
201        if let Ok(env_paths) = std::env::var("RUNTIMO_ALLOWED_PATHS") {
202            for p in env_paths.split(':').filter(|s| !s.is_empty()) {
203                let trimmed = p.trim().to_string();
204                if trimmed.is_empty() {
205                    continue;
206                }
207                if !prefixes.contains(&trimmed) {
208                    prefixes.push(trimmed);
209                }
210            }
211        }
212
213        // Config file
214        let config = Self::load();
215        for p in &config.allowed_paths {
216            let trimmed = p.trim().to_string();
217            if trimmed.is_empty() {
218                continue;
219            }
220            if !prefixes.contains(&trimmed) {
221                prefixes.push(trimmed);
222            }
223        }
224
225        prefixes
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::sync::Mutex;
233
234    /// Mutex to serialize config tests that set XDG_CONFIG_HOME.
235    /// Without this, concurrent tests fight over the process-global env var.
236    static CONFIG_TEST_MUTEX: Mutex<()> = Mutex::new(());
237
238    #[test]
239    fn config_path_is_absolute() {
240        let path = RuntimoConfig::config_path();
241        assert!(path.is_absolute());
242    }
243
244    #[test]
245    fn load_returns_defaults_when_no_file() {
246        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
247        let tmp = std::env::temp_dir().join("runtimo_test_config_defaults");
248        let _ = std::fs::remove_dir_all(&tmp);
249        std::env::set_var("XDG_CONFIG_HOME", &tmp);
250
251        let config = RuntimoConfig::load();
252        assert!(config.allowed_paths.is_empty());
253
254        let _ = std::fs::remove_dir_all(&tmp);
255        std::env::remove_var("XDG_CONFIG_HOME");
256    }
257
258    #[test]
259    fn get_allowed_prefixes_includes_defaults() {
260        let prefixes = RuntimoConfig::get_allowed_prefixes();
261        assert!(prefixes.iter().any(|p| p == "/tmp"));
262        assert!(prefixes.iter().any(|p| p == "/var/tmp"));
263        assert!(prefixes.iter().any(|p| p == "/home"));
264    }
265
266    #[test]
267    fn save_and_load_roundtrip() {
268        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
269        // Use a temp config path for this test
270        let tmp = std::env::temp_dir().join("runtimo_test_config");
271        std::env::set_var("XDG_CONFIG_HOME", &tmp);
272
273        let mut config = RuntimoConfig::default();
274        config.allowed_paths.push("/srv".to_string());
275        config.allowed_paths.push("/opt".to_string());
276        config.save().expect("save failed");
277
278        let loaded = RuntimoConfig::load();
279        assert_eq!(loaded.allowed_paths, vec!["/srv", "/opt"]);
280
281        let prefixes = RuntimoConfig::get_allowed_prefixes();
282        assert!(prefixes.contains(&"/srv".to_string()));
283        assert!(prefixes.contains(&"/opt".to_string()));
284
285        // Cleanup
286        let _ = std::fs::remove_dir_all(&tmp);
287        std::env::remove_var("XDG_CONFIG_HOME");
288    }
289
290    #[test]
291    fn test_toml_parse_failure_returns_defaults() {
292        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
293        // GAP 12: Corrupt TOML file returns defaults, not panic
294        let tmp = std::env::temp_dir().join("runtimo_test_config_corrupt");
295        let config_dir = tmp.join("runtimo");
296        let _ = std::fs::remove_dir_all(&tmp);
297        std::fs::create_dir_all(&config_dir).unwrap();
298        let config_path = config_dir.join("config.toml");
299
300        // Write corrupt TOML
301        std::fs::write(&config_path, "this is {{{ not valid toml at all!!!").unwrap();
302        std::env::set_var("XDG_CONFIG_HOME", &tmp);
303
304        let config = RuntimoConfig::load();
305        // Must return defaults, not panic
306        assert!(
307            config.allowed_paths.is_empty(),
308            "Corrupt TOML should return defaults"
309        );
310
311        let _ = std::fs::remove_dir_all(&tmp);
312        std::env::remove_var("XDG_CONFIG_HOME");
313    }
314
315    #[test]
316    fn test_empty_config_file_returns_defaults() {
317        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
318        // GAP 12: Empty config file returns defaults
319        let tmp = std::env::temp_dir().join("runtimo_test_config_empty");
320        let config_dir = tmp.join("runtimo");
321        let _ = std::fs::remove_dir_all(&tmp);
322        std::fs::create_dir_all(&config_dir).unwrap();
323        let config_path = config_dir.join("config.toml");
324
325        // Write empty file
326        std::fs::write(&config_path, "").unwrap();
327        std::env::set_var("XDG_CONFIG_HOME", &tmp);
328
329        let config = RuntimoConfig::load();
330        assert!(
331            config.allowed_paths.is_empty(),
332            "Empty config should return defaults"
333        );
334
335        let _ = std::fs::remove_dir_all(&tmp);
336        std::env::remove_var("XDG_CONFIG_HOME");
337    }
338
339    #[test]
340    fn test_toml_missing_section_returns_defaults() {
341        let _guard = CONFIG_TEST_MUTEX.lock().unwrap();
342        // GAP 12: Valid TOML but missing expected section
343        let tmp = std::env::temp_dir().join("runtimo_test_config_missing");
344        let config_dir = tmp.join("runtimo");
345        let _ = std::fs::remove_dir_all(&tmp);
346        std::fs::create_dir_all(&config_dir).unwrap();
347        let config_path = config_dir.join("config.toml");
348
349        // Valid TOML but no allowed_paths array
350        std::fs::write(&config_path, "[other_section]\nfoo = \"bar\"\n").unwrap();
351        std::env::set_var("XDG_CONFIG_HOME", &tmp);
352
353        let config = RuntimoConfig::load();
354        assert!(
355            config.allowed_paths.is_empty(),
356            "Missing section should return defaults"
357        );
358
359        let _ = std::fs::remove_dir_all(&tmp);
360        std::env::remove_var("XDG_CONFIG_HOME");
361    }
362}