Skip to main content

hermes_bot/
config.rs

1use crate::error::{HermesError, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6pub const DEFAULT_MODEL: &str = "claude-opus-4-6";
7
8#[derive(Deserialize)]
9pub struct Config {
10    pub slack: SlackConfig,
11    pub defaults: DefaultsConfig,
12    #[serde(default)]
13    pub tuning: TuningConfig,
14    #[serde(default)]
15    pub repos: HashMap<String, RepoConfig>,
16    /// Path to the session store file. Defaults to "sessions.json" in the working directory.
17    #[serde(default = "default_sessions_file")]
18    pub sessions_file: PathBuf,
19}
20
21fn default_sessions_file() -> PathBuf {
22    PathBuf::from("sessions.json")
23}
24
25impl std::fmt::Debug for Config {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("Config")
28            .field("slack", &self.slack)
29            .field("defaults", &self.defaults)
30            .field("tuning", &self.tuning)
31            .field("repos", &self.repos)
32            .field("sessions_file", &self.sessions_file)
33            .finish()
34    }
35}
36
37#[derive(Deserialize)]
38pub struct SlackConfig {
39    #[serde(default)]
40    pub app_token: String,
41    #[serde(default)]
42    pub bot_token: String,
43    #[serde(default)]
44    pub allowed_users: Vec<String>,
45}
46
47impl std::fmt::Debug for SlackConfig {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("SlackConfig")
50            .field("app_token", &"[REDACTED]")
51            .field("bot_token", &"[REDACTED]")
52            .field("allowed_users", &self.allowed_users)
53            .finish()
54    }
55}
56
57// ── Security Notes ─────────────────────────────────────────────────────
58
59// IMPORTANT: Token Security
60//
61// In production, tokens should NEVER be committed to version control.
62// Use one of these methods:
63//
64// 1. Environment variables (recommended):
65//    export SLACK_APP_TOKEN=xapp-...
66//    export SLACK_BOT_TOKEN=xoxb-...
67//
68// 2. Secret management service:
69//    - AWS Secrets Manager
70//    - HashiCorp Vault
71//    - Kubernetes Secrets
72//
73// 3. Encrypted .env file (not committed to git):
74//    - Add .env to .gitignore
75//    - Use tools like git-crypt or SOPS for encryption
76//
77// The config.toml file should only contain non-sensitive configuration.
78// Tokens loaded from environment variables will override config file values.
79
80#[derive(Debug, Deserialize)]
81pub struct DefaultsConfig {
82    #[serde(default)]
83    pub append_system_prompt: Option<String>,
84    #[serde(default)]
85    pub allowed_tools: Vec<String>,
86    #[serde(default)]
87    pub streaming_mode: StreamingMode,
88    #[serde(default)]
89    pub model: Option<String>,
90}
91
92#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
93#[serde(rename_all = "lowercase")]
94pub enum StreamingMode {
95    #[default]
96    Batch,
97    Live,
98}
99
100impl std::fmt::Display for StreamingMode {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            StreamingMode::Batch => write!(f, "batch"),
104            StreamingMode::Live => write!(f, "live"),
105        }
106    }
107}
108
109#[derive(Debug, Clone, Deserialize)]
110pub struct RepoConfig {
111    pub path: PathBuf,
112    #[serde(default = "default_agent")]
113    pub agent: AgentKind,
114    /// Custom Slack channel name. Defaults to the repo key name (dots replaced with hyphens).
115    pub channel: Option<String>,
116    #[serde(default)]
117    pub allowed_tools: Vec<String>,
118    #[serde(default)]
119    pub model: Option<String>,
120}
121
122#[derive(Debug, Clone, serde::Serialize, Deserialize, PartialEq, Eq, Hash)]
123#[serde(rename_all = "lowercase")]
124pub enum AgentKind {
125    Claude,
126}
127
128fn default_agent() -> AgentKind {
129    AgentKind::Claude
130}
131
132/// Performance and behavior tuning parameters.
133/// All fields have sensible defaults and are optional.
134#[derive(Debug, Clone, Deserialize)]
135#[serde(default)]
136pub struct TuningConfig {
137    /// Slack's approximate max message length (characters) for chat.postMessage. Default: 39000
138    pub slack_max_message_chars: usize,
139    /// Session time-to-live in days. Sessions older than this are pruned. Default: 7
140    pub session_ttl_days: i64,
141    /// Live-mode message update interval in seconds. Default: 2
142    pub live_update_interval_secs: u64,
143    /// Minimum interval between Slack API write calls per channel (ms). Default: 1100
144    pub rate_limit_interval_ms: u64,
145    /// Maximum accumulated text size in bytes before flushing (prevents unbounded growth). Default: 1000000
146    pub max_accumulated_text_bytes: usize,
147    /// Max retries for posting the first chunk of a thread. Default: 3
148    pub first_chunk_max_retries: u32,
149    /// Max length of message text shown in log previews. Default: 100
150    pub log_preview_max_len: usize,
151}
152
153impl Default for TuningConfig {
154    fn default() -> Self {
155        Self {
156            slack_max_message_chars: 39_000,
157            session_ttl_days: 7,
158            live_update_interval_secs: 2,
159            rate_limit_interval_ms: 1100,
160            max_accumulated_text_bytes: 1_000_000,
161            first_chunk_max_retries: 3,
162            log_preview_max_len: 100,
163        }
164    }
165}
166
167impl RepoConfig {
168    /// Returns allowed_tools merged with the global defaults.
169    pub fn merged_tools(&self, defaults: &DefaultsConfig) -> Vec<String> {
170        let mut tools = defaults.allowed_tools.clone();
171        for tool in &self.allowed_tools {
172            if !tools.contains(tool) {
173                tools.push(tool.clone());
174            }
175        }
176        tools
177    }
178
179    /// Returns the model for this repo: repo override > global default > DEFAULT_MODEL.
180    pub fn resolved_model(&self, defaults: &DefaultsConfig) -> String {
181        self.model
182            .clone()
183            .or_else(|| defaults.model.clone())
184            .unwrap_or_else(|| DEFAULT_MODEL.to_string())
185    }
186}
187
188impl Config {
189    pub fn load() -> Result<Self> {
190        let path = std::env::var("HERMES_CONFIG").unwrap_or_else(|_| "config.toml".into());
191        let contents = std::fs::read_to_string(&path).map_err(|e| {
192            HermesError::Config(format!("Failed to read config file '{}': {}", path, e))
193        })?;
194        let mut config: Config = toml::from_str(&contents)?;
195
196        // Env vars override config file for secrets.
197        if let Ok(val) = std::env::var("SLACK_APP_TOKEN") {
198            config.slack.app_token = val;
199        }
200        if let Ok(val) = std::env::var("SLACK_BOT_TOKEN") {
201            config.slack.bot_token = val;
202        }
203
204        if config.slack.app_token.is_empty() {
205            return Err(HermesError::Config(
206                "Slack app token not set. Use SLACK_APP_TOKEN env var or slack.app_token in config."
207                    .into(),
208            ));
209        }
210        if config.slack.bot_token.is_empty() {
211            return Err(HermesError::Config(
212                "Slack bot token not set. Use SLACK_BOT_TOKEN env var or slack.bot_token in config."
213                    .into(),
214            ));
215        }
216
217        config.validate()?;
218        Ok(config)
219    }
220
221    fn validate(&self) -> Result<()> {
222        if !self.slack.app_token.starts_with("xapp-") {
223            return Err(HermesError::Config(
224                "Slack app_token should start with 'xapp-'. Did you swap app_token and bot_token?"
225                    .into(),
226            ));
227        }
228        if !self.slack.bot_token.starts_with("xoxb-") {
229            return Err(HermesError::Config(
230                "Slack bot_token should start with 'xoxb-'. Did you swap app_token and bot_token?"
231                    .into(),
232            ));
233        }
234
235        if self.repos.is_empty() {
236            return Err(HermesError::Config(
237                "No repos configured. Add at least one [repos.<name>] section.".into(),
238            ));
239        }
240
241        // Validate sessions_file path for security
242        if let Some(path_str) = self.sessions_file.to_str() {
243            if path_str.contains("..") {
244                tracing::warn!(
245                    "sessions_file contains '..': {}. This may be a path traversal risk.",
246                    path_str
247                );
248            }
249            // Warn if absolute path outside current directory (potential security issue)
250            if self.sessions_file.is_absolute() {
251                tracing::info!(
252                    "sessions_file uses absolute path: {}. Ensure proper permissions.",
253                    self.sessions_file.display()
254                );
255            }
256        }
257
258        for (name, repo) in &self.repos {
259            if !repo.path.exists() {
260                return Err(HermesError::Config(format!(
261                    "Repo '{}' path does not exist: {}",
262                    name,
263                    repo.path.display()
264                )));
265            }
266
267            // Security: Warn about relative paths with .. (path traversal risk)
268            if let Some(path_str) = repo.path.to_str() {
269                if path_str.contains("..") {
270                    tracing::warn!(
271                        "Repo '{}' path contains '..': {}. Verify this is intentional.",
272                        name,
273                        path_str
274                    );
275                }
276            }
277        }
278
279        Ok(())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use rstest::rstest;
287
288    fn minimal_config(repos_path: &str) -> String {
289        format!(
290            r#"
291[slack]
292app_token = "xapp-test"
293bot_token = "xoxb-test"
294allowed_users = ["U123"]
295
296[defaults]
297streaming_mode = "batch"
298
299[repos.test]
300path = "{}"
301"#,
302            repos_path
303        )
304    }
305
306    #[test]
307    fn test_parse_minimal_config() {
308        let toml = minimal_config("/tmp");
309        let config: Config = toml::from_str(&toml).unwrap();
310        assert_eq!(config.slack.app_token, "xapp-test");
311        assert_eq!(config.slack.bot_token, "xoxb-test");
312        assert_eq!(config.slack.allowed_users, vec!["U123"]);
313        assert_eq!(config.defaults.streaming_mode, StreamingMode::Batch);
314        assert!(config.repos.contains_key("test"));
315    }
316
317    #[test]
318    fn test_streaming_mode_live() {
319        let toml = r#"
320[slack]
321[defaults]
322streaming_mode = "live"
323"#;
324        let config: Config = toml::from_str(toml).unwrap();
325        assert_eq!(config.defaults.streaming_mode, StreamingMode::Live);
326    }
327
328    #[test]
329    fn test_streaming_mode_defaults_to_batch() {
330        let toml = r#"
331[slack]
332[defaults]
333"#;
334        let config: Config = toml::from_str(toml).unwrap();
335        assert_eq!(config.defaults.streaming_mode, StreamingMode::Batch);
336    }
337
338    #[test]
339    fn test_sessions_file_defaults() {
340        let toml = r#"
341[slack]
342[defaults]
343"#;
344        let config: Config = toml::from_str(toml).unwrap();
345        assert_eq!(config.sessions_file, PathBuf::from("sessions.json"));
346    }
347
348    #[test]
349    fn test_sessions_file_custom() {
350        let toml = r#"
351sessions_file = "/var/lib/hermes/sessions.json"
352[slack]
353[defaults]
354"#;
355        let config: Config = toml::from_str(toml).unwrap();
356        assert_eq!(
357            config.sessions_file,
358            PathBuf::from("/var/lib/hermes/sessions.json")
359        );
360    }
361
362    // Helper to create defaults config for merged_tools tests
363    fn make_defaults(tools: Vec<&str>) -> DefaultsConfig {
364        DefaultsConfig {
365            append_system_prompt: None,
366            allowed_tools: tools.iter().map(|s| s.to_string()).collect(),
367            streaming_mode: StreamingMode::Batch,
368            model: None,
369        }
370    }
371
372    // Helper to create repo config for merged_tools tests
373    fn make_repo(tools: Vec<&str>) -> RepoConfig {
374        RepoConfig {
375            path: PathBuf::from("/tmp"),
376            agent: AgentKind::Claude,
377            channel: None,
378            allowed_tools: tools.iter().map(|s| s.to_string()).collect(),
379            model: None,
380        }
381    }
382
383    #[rstest]
384    #[case(
385        vec!["Read", "Grep"],
386        vec!["Edit", "Write"],
387        vec!["Read", "Grep", "Edit", "Write"],
388        "combines defaults and repo tools"
389    )]
390    #[case(
391        vec!["Read", "Grep"],
392        vec!["Read", "Edit"],
393        vec!["Read", "Grep", "Edit"],
394        "deduplicates tools"
395    )]
396    #[case(
397        vec!["Read"],
398        vec![],
399        vec!["Read"],
400        "empty repo tools uses only defaults"
401    )]
402    fn test_merged_tools(
403        #[case] defaults_tools: Vec<&str>,
404        #[case] repo_tools: Vec<&str>,
405        #[case] expected: Vec<&str>,
406        #[case] description: &str,
407    ) {
408        let defaults = make_defaults(defaults_tools);
409        let repo = make_repo(repo_tools);
410        let merged = repo.merged_tools(&defaults);
411        assert_eq!(merged, expected, "{}", description);
412    }
413
414    #[test]
415    fn test_debug_redacts_tokens() {
416        let config: Config = toml::from_str(
417            r#"
418[slack]
419app_token = "xapp-secret-123"
420bot_token = "xoxb-secret-456"
421[defaults]
422"#,
423        )
424        .unwrap();
425        let debug_output = format!("{:?}", config);
426        assert!(!debug_output.contains("xapp-secret-123"));
427        assert!(!debug_output.contains("xoxb-secret-456"));
428        assert!(debug_output.contains("[REDACTED]"));
429    }
430
431    #[test]
432    fn test_agent_kind_defaults_to_claude() {
433        let toml = r#"
434[slack]
435[defaults]
436[repos.test]
437path = "/tmp"
438"#;
439        let config: Config = toml::from_str(toml).unwrap();
440        assert_eq!(config.repos["test"].agent, AgentKind::Claude);
441    }
442
443    #[test]
444    fn test_validate_rejects_no_repos() {
445        let toml = r#"
446[slack]
447app_token = "xapp-test"
448bot_token = "xoxb-test"
449[defaults]
450"#;
451        let config: Config = toml::from_str(toml).unwrap();
452        let result = config.validate();
453        assert!(result.is_err());
454        assert!(result
455            .unwrap_err()
456            .to_string()
457            .contains("No repos configured"));
458    }
459
460    #[test]
461    fn test_validate_rejects_nonexistent_path() {
462        let toml = r#"
463[slack]
464app_token = "xapp-test"
465bot_token = "xoxb-test"
466[defaults]
467[repos.test]
468path = "/nonexistent/path/that/should/not/exist"
469"#;
470        let config: Config = toml::from_str(toml).unwrap();
471        let result = config.validate();
472        assert!(result.is_err());
473        assert!(result.unwrap_err().to_string().contains("does not exist"));
474    }
475
476    #[test]
477    fn test_streaming_mode_display() {
478        assert_eq!(StreamingMode::Batch.to_string(), "batch");
479        assert_eq!(StreamingMode::Live.to_string(), "live");
480    }
481
482    #[test]
483    fn test_validate_rejects_bad_app_token_prefix() {
484        let toml = r#"
485[slack]
486app_token = "xoxb-wrong-prefix"
487bot_token = "xoxb-test"
488[defaults]
489[repos.test]
490path = "/tmp"
491"#;
492        let config: Config = toml::from_str(toml).unwrap();
493        let result = config.validate();
494        assert!(result.is_err());
495        assert!(result.unwrap_err().to_string().contains("xapp-"));
496    }
497
498    #[test]
499    fn test_validate_rejects_bad_bot_token_prefix() {
500        let toml = r#"
501[slack]
502app_token = "xapp-test"
503bot_token = "xapp-wrong-prefix"
504[defaults]
505[repos.test]
506path = "/tmp"
507"#;
508        let config: Config = toml::from_str(toml).unwrap();
509        let result = config.validate();
510        assert!(result.is_err());
511        assert!(result.unwrap_err().to_string().contains("xoxb-"));
512    }
513
514    #[test]
515    fn test_resolved_model_defaults() {
516        let defaults = DefaultsConfig {
517            append_system_prompt: None,
518            allowed_tools: vec![],
519            streaming_mode: StreamingMode::Batch,
520            model: None,
521        };
522        let repo = RepoConfig {
523            path: PathBuf::from("/tmp"),
524            agent: AgentKind::Claude,
525            channel: None,
526            allowed_tools: vec![],
527            model: None,
528        };
529        assert_eq!(repo.resolved_model(&defaults), DEFAULT_MODEL);
530    }
531
532    #[test]
533    fn test_resolved_model_global_override() {
534        let defaults = DefaultsConfig {
535            append_system_prompt: None,
536            allowed_tools: vec![],
537            streaming_mode: StreamingMode::Batch,
538            model: Some("claude-sonnet-4-5-20250929".to_string()),
539        };
540        let repo = RepoConfig {
541            path: PathBuf::from("/tmp"),
542            agent: AgentKind::Claude,
543            channel: None,
544            allowed_tools: vec![],
545            model: None,
546        };
547        assert_eq!(repo.resolved_model(&defaults), "claude-sonnet-4-5-20250929");
548    }
549
550    #[test]
551    fn test_resolved_model_repo_override() {
552        let defaults = DefaultsConfig {
553            append_system_prompt: None,
554            allowed_tools: vec![],
555            streaming_mode: StreamingMode::Batch,
556            model: Some("claude-sonnet-4-5-20250929".to_string()),
557        };
558        let repo = RepoConfig {
559            path: PathBuf::from("/tmp"),
560            agent: AgentKind::Claude,
561            channel: None,
562            allowed_tools: vec![],
563            model: Some("claude-haiku-4-5-20251001".to_string()),
564        };
565        assert_eq!(repo.resolved_model(&defaults), "claude-haiku-4-5-20251001");
566    }
567}