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 database. Defaults to "sessions.db" 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.db")
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    /// Enable syncing local Claude Code sessions into Slack. Default: true.
91    #[serde(default = "default_true")]
92    pub sync_local_sessions: bool,
93}
94
95fn default_true() -> bool {
96    true
97}
98
99#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum StreamingMode {
102    #[default]
103    Batch,
104    Live,
105}
106
107impl std::fmt::Display for StreamingMode {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            StreamingMode::Batch => write!(f, "batch"),
111            StreamingMode::Live => write!(f, "live"),
112        }
113    }
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct RepoConfig {
118    pub path: PathBuf,
119    #[serde(default = "default_agent")]
120    pub agent: AgentKind,
121    /// Custom Slack channel name. Defaults to the repo key name (dots replaced with hyphens).
122    pub channel: Option<String>,
123    #[serde(default)]
124    pub allowed_tools: Vec<String>,
125    #[serde(default)]
126    pub model: Option<String>,
127    /// Override the global sync_local_sessions setting for this repo.
128    #[serde(default)]
129    pub sync_local_sessions: Option<bool>,
130}
131
132#[derive(Debug, Clone, serde::Serialize, Deserialize, PartialEq, Eq, Hash)]
133#[serde(rename_all = "lowercase")]
134pub enum AgentKind {
135    Claude,
136}
137
138fn default_agent() -> AgentKind {
139    AgentKind::Claude
140}
141
142/// Performance and behavior tuning parameters.
143/// All fields have sensible defaults and are optional.
144#[derive(Debug, Clone, Deserialize)]
145#[serde(default)]
146pub struct TuningConfig {
147    /// Slack's approximate max message length (characters) for chat.postMessage. Default: 39000
148    pub slack_max_message_chars: usize,
149    /// Session time-to-live in days. Sessions older than this are pruned. Default: 7
150    pub session_ttl_days: i64,
151    /// Live-mode message update interval in seconds. Default: 2
152    pub live_update_interval_secs: u64,
153    /// Minimum interval between Slack API write calls per channel (ms). Default: 1100
154    pub rate_limit_interval_ms: u64,
155    /// Maximum accumulated text size in bytes before flushing (prevents unbounded growth). Default: 1000000
156    pub max_accumulated_text_bytes: usize,
157    /// Max retries for posting the first chunk of a thread. Default: 3
158    pub first_chunk_max_retries: u32,
159    /// Max length of message text shown in log previews. Default: 100
160    pub log_preview_max_len: usize,
161}
162
163impl Default for TuningConfig {
164    fn default() -> Self {
165        Self {
166            slack_max_message_chars: 39_000,
167            session_ttl_days: 7,
168            live_update_interval_secs: 2,
169            rate_limit_interval_ms: 1100,
170            max_accumulated_text_bytes: 1_000_000,
171            first_chunk_max_retries: 3,
172            log_preview_max_len: 100,
173        }
174    }
175}
176
177impl RepoConfig {
178    /// Returns allowed_tools merged with the global defaults.
179    pub fn merged_tools(&self, defaults: &DefaultsConfig) -> Vec<String> {
180        let mut tools = defaults.allowed_tools.clone();
181        for tool in &self.allowed_tools {
182            if !tools.contains(tool) {
183                tools.push(tool.clone());
184            }
185        }
186        tools
187    }
188
189    /// Returns whether local session sync is enabled: repo override > global default (true).
190    pub fn sync_enabled(&self, defaults: &DefaultsConfig) -> bool {
191        self.sync_local_sessions
192            .unwrap_or(defaults.sync_local_sessions)
193    }
194
195    /// Returns the model for this repo: repo override > global default > DEFAULT_MODEL.
196    pub fn resolved_model(&self, defaults: &DefaultsConfig) -> String {
197        self.model
198            .clone()
199            .or_else(|| defaults.model.clone())
200            .unwrap_or_else(|| DEFAULT_MODEL.to_string())
201    }
202}
203
204impl Config {
205    pub fn load() -> Result<Self> {
206        let path = std::env::var("HERMES_CONFIG").unwrap_or_else(|_| "config.toml".into());
207        let contents = std::fs::read_to_string(&path).map_err(|e| {
208            HermesError::Config(format!("Failed to read config file '{}': {}", path, e))
209        })?;
210        let mut config: Config = toml::from_str(&contents)?;
211
212        // Env vars override config file for secrets.
213        if let Ok(val) = std::env::var("SLACK_APP_TOKEN") {
214            config.slack.app_token = val;
215        }
216        if let Ok(val) = std::env::var("SLACK_BOT_TOKEN") {
217            config.slack.bot_token = val;
218        }
219
220        if config.slack.app_token.is_empty() {
221            return Err(HermesError::Config(
222                "Slack app token not set. Use SLACK_APP_TOKEN env var or slack.app_token in config."
223                    .into(),
224            ));
225        }
226        if config.slack.bot_token.is_empty() {
227            return Err(HermesError::Config(
228                "Slack bot token not set. Use SLACK_BOT_TOKEN env var or slack.bot_token in config."
229                    .into(),
230            ));
231        }
232
233        config.validate()?;
234        Ok(config)
235    }
236
237    fn validate(&self) -> Result<()> {
238        if !self.slack.app_token.starts_with("xapp-") {
239            return Err(HermesError::Config(
240                "Slack app_token should start with 'xapp-'. Did you swap app_token and bot_token?"
241                    .into(),
242            ));
243        }
244        if !self.slack.bot_token.starts_with("xoxb-") {
245            return Err(HermesError::Config(
246                "Slack bot_token should start with 'xoxb-'. Did you swap app_token and bot_token?"
247                    .into(),
248            ));
249        }
250
251        if self.repos.is_empty() {
252            return Err(HermesError::Config(
253                "No repos configured. Add at least one [repos.<name>] section.".into(),
254            ));
255        }
256
257        // Validate sessions_file path for security
258        if let Some(path_str) = self.sessions_file.to_str() {
259            if path_str.contains("..") {
260                tracing::warn!(
261                    "sessions_file contains '..': {}. This may be a path traversal risk.",
262                    path_str
263                );
264            }
265            // Warn if absolute path outside current directory (potential security issue)
266            if self.sessions_file.is_absolute() {
267                tracing::info!(
268                    "sessions_file uses absolute path: {}. Ensure proper permissions.",
269                    self.sessions_file.display()
270                );
271            }
272        }
273
274        for (name, repo) in &self.repos {
275            if !repo.path.exists() {
276                return Err(HermesError::Config(format!(
277                    "Repo '{}' path does not exist: {}",
278                    name,
279                    repo.path.display()
280                )));
281            }
282
283            // Security: Warn about relative paths with .. (path traversal risk)
284            if let Some(path_str) = repo.path.to_str()
285                && path_str.contains("..")
286            {
287                tracing::warn!(
288                    "Repo '{}' path contains '..': {}. Verify this is intentional.",
289                    name,
290                    path_str
291                );
292            }
293        }
294
295        Ok(())
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use rstest::rstest;
303
304    fn minimal_config(repos_path: &str) -> String {
305        format!(
306            r#"
307[slack]
308app_token = "xapp-test"
309bot_token = "xoxb-test"
310allowed_users = ["U123"]
311
312[defaults]
313streaming_mode = "batch"
314
315[repos.test]
316path = "{}"
317"#,
318            repos_path
319        )
320    }
321
322    #[test]
323    fn test_parse_minimal_config() {
324        let toml = minimal_config("/tmp");
325        let config: Config = toml::from_str(&toml).unwrap();
326        assert_eq!(config.slack.app_token, "xapp-test");
327        assert_eq!(config.slack.bot_token, "xoxb-test");
328        assert_eq!(config.slack.allowed_users, vec!["U123"]);
329        assert_eq!(config.defaults.streaming_mode, StreamingMode::Batch);
330        assert!(config.repos.contains_key("test"));
331    }
332
333    #[test]
334    fn test_streaming_mode_live() {
335        let toml = r#"
336[slack]
337[defaults]
338streaming_mode = "live"
339"#;
340        let config: Config = toml::from_str(toml).unwrap();
341        assert_eq!(config.defaults.streaming_mode, StreamingMode::Live);
342    }
343
344    #[test]
345    fn test_streaming_mode_defaults_to_batch() {
346        let toml = r#"
347[slack]
348[defaults]
349"#;
350        let config: Config = toml::from_str(toml).unwrap();
351        assert_eq!(config.defaults.streaming_mode, StreamingMode::Batch);
352    }
353
354    #[test]
355    fn test_sessions_file_defaults() {
356        let toml = r#"
357[slack]
358[defaults]
359"#;
360        let config: Config = toml::from_str(toml).unwrap();
361        assert_eq!(config.sessions_file, PathBuf::from("sessions.db"));
362    }
363
364    #[test]
365    fn test_sessions_file_custom() {
366        let toml = r#"
367sessions_file = "/var/lib/hermes/sessions.db"
368[slack]
369[defaults]
370"#;
371        let config: Config = toml::from_str(toml).unwrap();
372        assert_eq!(
373            config.sessions_file,
374            PathBuf::from("/var/lib/hermes/sessions.db")
375        );
376    }
377
378    // Helper to create defaults config for merged_tools tests
379    fn make_defaults(tools: Vec<&str>) -> DefaultsConfig {
380        DefaultsConfig {
381            append_system_prompt: None,
382            allowed_tools: tools.iter().map(|s| s.to_string()).collect(),
383            streaming_mode: StreamingMode::Batch,
384            model: None,
385            sync_local_sessions: true,
386        }
387    }
388
389    // Helper to create repo config for merged_tools tests
390    fn make_repo(tools: Vec<&str>) -> RepoConfig {
391        RepoConfig {
392            path: PathBuf::from("/tmp"),
393            agent: AgentKind::Claude,
394            channel: None,
395            allowed_tools: tools.iter().map(|s| s.to_string()).collect(),
396            model: None,
397            sync_local_sessions: None,
398        }
399    }
400
401    #[rstest]
402    #[case(
403        vec!["Read", "Grep"],
404        vec!["Edit", "Write"],
405        vec!["Read", "Grep", "Edit", "Write"],
406        "combines defaults and repo tools"
407    )]
408    #[case(
409        vec!["Read", "Grep"],
410        vec!["Read", "Edit"],
411        vec!["Read", "Grep", "Edit"],
412        "deduplicates tools"
413    )]
414    #[case(
415        vec!["Read"],
416        vec![],
417        vec!["Read"],
418        "empty repo tools uses only defaults"
419    )]
420    fn test_merged_tools(
421        #[case] defaults_tools: Vec<&str>,
422        #[case] repo_tools: Vec<&str>,
423        #[case] expected: Vec<&str>,
424        #[case] description: &str,
425    ) {
426        let defaults = make_defaults(defaults_tools);
427        let repo = make_repo(repo_tools);
428        let merged = repo.merged_tools(&defaults);
429        assert_eq!(merged, expected, "{}", description);
430    }
431
432    #[test]
433    fn test_debug_redacts_tokens() {
434        let config: Config = toml::from_str(
435            r#"
436[slack]
437app_token = "xapp-secret-123"
438bot_token = "xoxb-secret-456"
439[defaults]
440"#,
441        )
442        .unwrap();
443        let debug_output = format!("{:?}", config);
444        assert!(!debug_output.contains("xapp-secret-123"));
445        assert!(!debug_output.contains("xoxb-secret-456"));
446        assert!(debug_output.contains("[REDACTED]"));
447    }
448
449    #[test]
450    fn test_agent_kind_defaults_to_claude() {
451        let toml = r#"
452[slack]
453[defaults]
454[repos.test]
455path = "/tmp"
456"#;
457        let config: Config = toml::from_str(toml).unwrap();
458        assert_eq!(config.repos["test"].agent, AgentKind::Claude);
459    }
460
461    #[test]
462    fn test_validate_rejects_no_repos() {
463        let toml = r#"
464[slack]
465app_token = "xapp-test"
466bot_token = "xoxb-test"
467[defaults]
468"#;
469        let config: Config = toml::from_str(toml).unwrap();
470        let result = config.validate();
471        assert!(result.is_err());
472        assert!(
473            result
474                .unwrap_err()
475                .to_string()
476                .contains("No repos configured")
477        );
478    }
479
480    #[test]
481    fn test_validate_rejects_nonexistent_path() {
482        let toml = r#"
483[slack]
484app_token = "xapp-test"
485bot_token = "xoxb-test"
486[defaults]
487[repos.test]
488path = "/nonexistent/path/that/should/not/exist"
489"#;
490        let config: Config = toml::from_str(toml).unwrap();
491        let result = config.validate();
492        assert!(result.is_err());
493        assert!(result.unwrap_err().to_string().contains("does not exist"));
494    }
495
496    #[test]
497    fn test_streaming_mode_display() {
498        assert_eq!(StreamingMode::Batch.to_string(), "batch");
499        assert_eq!(StreamingMode::Live.to_string(), "live");
500    }
501
502    #[test]
503    fn test_validate_rejects_bad_app_token_prefix() {
504        let toml = r#"
505[slack]
506app_token = "xoxb-wrong-prefix"
507bot_token = "xoxb-test"
508[defaults]
509[repos.test]
510path = "/tmp"
511"#;
512        let config: Config = toml::from_str(toml).unwrap();
513        let result = config.validate();
514        assert!(result.is_err());
515        assert!(result.unwrap_err().to_string().contains("xapp-"));
516    }
517
518    #[test]
519    fn test_validate_rejects_bad_bot_token_prefix() {
520        let toml = r#"
521[slack]
522app_token = "xapp-test"
523bot_token = "xapp-wrong-prefix"
524[defaults]
525[repos.test]
526path = "/tmp"
527"#;
528        let config: Config = toml::from_str(toml).unwrap();
529        let result = config.validate();
530        assert!(result.is_err());
531        assert!(result.unwrap_err().to_string().contains("xoxb-"));
532    }
533
534    #[test]
535    fn test_resolved_model_defaults() {
536        let defaults = DefaultsConfig {
537            append_system_prompt: None,
538            allowed_tools: vec![],
539            streaming_mode: StreamingMode::Batch,
540            model: None,
541            sync_local_sessions: true,
542        };
543        let repo = RepoConfig {
544            path: PathBuf::from("/tmp"),
545            agent: AgentKind::Claude,
546            channel: None,
547            allowed_tools: vec![],
548            model: None,
549            sync_local_sessions: None,
550        };
551        assert_eq!(repo.resolved_model(&defaults), DEFAULT_MODEL);
552    }
553
554    #[test]
555    fn test_resolved_model_global_override() {
556        let defaults = DefaultsConfig {
557            append_system_prompt: None,
558            allowed_tools: vec![],
559            streaming_mode: StreamingMode::Batch,
560            model: Some("claude-sonnet-4-5-20250929".to_string()),
561            sync_local_sessions: true,
562        };
563        let repo = RepoConfig {
564            path: PathBuf::from("/tmp"),
565            agent: AgentKind::Claude,
566            channel: None,
567            allowed_tools: vec![],
568            model: None,
569            sync_local_sessions: None,
570        };
571        assert_eq!(repo.resolved_model(&defaults), "claude-sonnet-4-5-20250929");
572    }
573
574    #[test]
575    fn test_resolved_model_repo_override() {
576        let defaults = DefaultsConfig {
577            append_system_prompt: None,
578            allowed_tools: vec![],
579            streaming_mode: StreamingMode::Batch,
580            model: Some("claude-sonnet-4-5-20250929".to_string()),
581            sync_local_sessions: true,
582        };
583        let repo = RepoConfig {
584            path: PathBuf::from("/tmp"),
585            agent: AgentKind::Claude,
586            channel: None,
587            allowed_tools: vec![],
588            model: Some("claude-haiku-4-5-20251001".to_string()),
589            sync_local_sessions: None,
590        };
591        assert_eq!(repo.resolved_model(&defaults), "claude-haiku-4-5-20251001");
592    }
593
594    #[test]
595    fn test_sync_enabled_defaults_to_true() {
596        let defaults = make_defaults(vec![]);
597        let repo = make_repo(vec![]);
598        assert!(repo.sync_enabled(&defaults));
599    }
600
601    #[test]
602    fn test_sync_enabled_global_disable() {
603        let mut defaults = make_defaults(vec![]);
604        defaults.sync_local_sessions = false;
605        let repo = make_repo(vec![]);
606        assert!(!repo.sync_enabled(&defaults));
607    }
608
609    #[test]
610    fn test_sync_enabled_repo_override_enable() {
611        let mut defaults = make_defaults(vec![]);
612        defaults.sync_local_sessions = false;
613        let mut repo = make_repo(vec![]);
614        repo.sync_local_sessions = Some(true);
615        assert!(repo.sync_enabled(&defaults));
616    }
617
618    #[test]
619    fn test_sync_enabled_repo_override_disable() {
620        let defaults = make_defaults(vec![]);
621        let mut repo = make_repo(vec![]);
622        repo.sync_local_sessions = Some(false);
623        assert!(!repo.sync_enabled(&defaults));
624    }
625}