Skip to main content

cc_toolgate/
config.rs

1//! Configuration loading and overlay merge logic.
2//!
3//! cc-toolgate ships with sensible defaults embedded in the binary via
4//! `config.default.toml`. Users can override any part by placing a
5//! `config.toml` at `~/.config/cc-toolgate/config.toml`. The user config
6//! **merges** with defaults: lists extend (deduplicated), scalars override,
7//! `remove_<field>` subtracts, and `replace = true` replaces entirely.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Embedded default configuration (compiled into the binary from `config.default.toml`).
13const DEFAULT_CONFIG: &str = include_str!("../config.default.toml");
14
15// ── Final (merged) config types ──
16
17/// Top-level configuration, produced by merging embedded defaults with
18/// an optional user overlay from `~/.config/cc-toolgate/config.toml`.
19#[derive(Debug, Deserialize, Serialize)]
20pub struct Config {
21    /// Global settings (e.g. escalate_deny).
22    #[serde(default)]
23    pub settings: Settings,
24    /// Flat command-to-decision mappings (allow, ask, deny lists).
25    #[serde(default)]
26    pub commands: Commands,
27    /// Wrapper commands that execute their arguments as subcommands.
28    #[serde(default)]
29    pub wrappers: WrapperConfig,
30    /// Git subcommand-aware evaluation rules.
31    #[serde(default)]
32    pub git: GitConfig,
33    /// Cargo subcommand-aware evaluation rules.
34    #[serde(default)]
35    pub cargo: CargoConfig,
36    /// kubectl subcommand-aware evaluation rules.
37    #[serde(default)]
38    pub kubectl: KubectlConfig,
39    /// GitHub CLI (gh) subcommand-aware evaluation rules.
40    #[serde(default)]
41    pub gh: GhConfig,
42}
43
44/// Global settings that affect evaluation behavior.
45#[derive(Debug, Deserialize, Serialize, Default)]
46pub struct Settings {
47    /// When true, DENY decisions are escalated to ASK (the user is prompted
48    /// instead of being blocked). Useful for operators who want visibility
49    /// without hard blocks.
50    #[serde(default)]
51    pub escalate_deny: bool,
52}
53
54/// Flat command name → decision mappings for simple commands.
55///
56/// Commands in `allow` run silently, `ask` prompts the user, `deny` blocks outright.
57/// Unrecognized commands default to ASK.
58#[derive(Debug, Deserialize, Serialize, Default)]
59pub struct Commands {
60    /// Commands that run silently (e.g. `ls`, `cat`, `grep`).
61    #[serde(default)]
62    pub allow: Vec<String>,
63    /// Commands that require user confirmation (e.g. `rm`, `curl`, `pip`).
64    #[serde(default)]
65    pub ask: Vec<String>,
66    /// Commands that are blocked outright (e.g. `shred`, `dd`, `mkfs`).
67    #[serde(default)]
68    pub deny: Vec<String>,
69}
70
71/// Commands that execute their arguments as subcommands.
72/// The wrapped command is extracted and evaluated; the final decision
73/// is max(floor, wrapped_command_decision).
74#[derive(Debug, Deserialize, Serialize, Default)]
75pub struct WrapperConfig {
76    /// Wrappers with Allow floor: wrapper is safe, wrapped command determines disposition.
77    /// e.g. xargs, parallel, env, nohup, nice, timeout, time, watch
78    #[serde(default)]
79    pub allow_floor: Vec<String>,
80    /// Wrappers with Ask floor: always at least Ask, wrapped command can escalate to Deny.
81    /// e.g. sudo, doas, pkexec
82    #[serde(default)]
83    pub ask_floor: Vec<String>,
84}
85
86/// Git subcommand evaluation rules.
87#[derive(Debug, Deserialize, Serialize, Default)]
88pub struct GitConfig {
89    /// Subcommands that are always allowed (e.g. `status`, `log`, `diff`, `branch`).
90    #[serde(default)]
91    pub read_only: Vec<String>,
92    /// Subcommands that are allowed only when all `config_env` entries match
93    /// (e.g. `push`, `pull` when `GIT_CONFIG_GLOBAL=~/.gitconfig.ai`).
94    #[serde(default)]
95    pub allowed_with_config: Vec<String>,
96    /// Environment variable requirements for `allowed_with_config` subcommands.
97    /// Each entry maps a var name to its required value. All must match (AND).
98    /// Checked in the command's inline env first, then the process environment.
99    /// When empty, the env-gating feature is disabled and those commands always ASK.
100    #[serde(default)]
101    pub config_env: HashMap<String, String>,
102    /// Flags that indicate a force-push (e.g. `--force`, `-f`, `--force-with-lease`).
103    /// Force-pushes always require confirmation regardless of env-gating.
104    #[serde(default)]
105    pub force_push_flags: Vec<String>,
106}
107
108/// Cargo subcommand evaluation rules.
109#[derive(Debug, Deserialize, Serialize, Default)]
110pub struct CargoConfig {
111    /// Subcommands that are always allowed (e.g. `build`, `test`, `check`, `clippy`).
112    #[serde(default)]
113    pub safe_subcommands: Vec<String>,
114    /// Subcommands allowed only when all `config_env` entries match.
115    #[serde(default)]
116    pub allowed_with_config: Vec<String>,
117    /// Environment variable requirements for `allowed_with_config` subcommands.
118    #[serde(default)]
119    pub config_env: HashMap<String, String>,
120}
121
122/// kubectl subcommand evaluation rules.
123#[derive(Debug, Deserialize, Serialize, Default)]
124pub struct KubectlConfig {
125    /// Read-only subcommands that are always allowed (e.g. `get`, `describe`, `logs`).
126    #[serde(default)]
127    pub read_only: Vec<String>,
128    /// Known mutating subcommands that always require confirmation (e.g. `apply`, `delete`).
129    #[serde(default)]
130    pub mutating: Vec<String>,
131    /// Subcommands allowed only when all `config_env` entries match.
132    #[serde(default)]
133    pub allowed_with_config: Vec<String>,
134    /// Environment variable requirements for `allowed_with_config` subcommands.
135    #[serde(default)]
136    pub config_env: HashMap<String, String>,
137}
138
139/// GitHub CLI (gh) subcommand evaluation rules.
140///
141/// gh uses two-word subcommands (e.g. `pr list`, `issue create`), so
142/// both two-word and one-word matches are checked.
143#[derive(Debug, Deserialize, Serialize, Default)]
144pub struct GhConfig {
145    /// Read-only subcommands (e.g. `pr list`, `pr view`, `status`, `api`).
146    #[serde(default)]
147    pub read_only: Vec<String>,
148    /// Known mutating subcommands (e.g. `pr create`, `pr merge`, `repo delete`).
149    #[serde(default)]
150    pub mutating: Vec<String>,
151    /// Subcommands allowed only when all `config_env` entries match.
152    #[serde(default)]
153    pub allowed_with_config: Vec<String>,
154    /// Environment variable requirements for `allowed_with_config` subcommands.
155    #[serde(default)]
156    pub config_env: HashMap<String, String>,
157}
158
159// ── Overlay types (user config that merges with defaults) ──
160//
161// These mirror the public config types but use `Option` for scalars and
162// include `replace` flags and `remove_*` lists for the merge system.
163
164/// User-provided configuration overlay, deserialized from `~/.config/cc-toolgate/config.toml`.
165#[derive(Debug, Deserialize, Default)]
166struct ConfigOverlay {
167    #[serde(default)]
168    settings: SettingsOverlay,
169    #[serde(default)]
170    commands: CommandsOverlay,
171    #[serde(default)]
172    wrappers: WrappersOverlay,
173    #[serde(default)]
174    git: GitOverlay,
175    #[serde(default)]
176    cargo: CargoOverlay,
177    #[serde(default)]
178    kubectl: KubectlOverlay,
179    #[serde(default)]
180    gh: GhOverlay,
181}
182
183#[derive(Debug, Deserialize, Default)]
184struct SettingsOverlay {
185    escalate_deny: Option<bool>,
186}
187
188#[derive(Debug, Deserialize, Default)]
189struct WrappersOverlay {
190    #[serde(default)]
191    replace: bool,
192    #[serde(default)]
193    allow_floor: Vec<String>,
194    #[serde(default)]
195    ask_floor: Vec<String>,
196    #[serde(default)]
197    remove_allow_floor: Vec<String>,
198    #[serde(default)]
199    remove_ask_floor: Vec<String>,
200}
201
202#[derive(Debug, Deserialize, Default)]
203struct CommandsOverlay {
204    #[serde(default)]
205    replace: bool,
206    #[serde(default)]
207    allow: Vec<String>,
208    #[serde(default)]
209    ask: Vec<String>,
210    #[serde(default)]
211    deny: Vec<String>,
212    #[serde(default)]
213    remove_allow: Vec<String>,
214    #[serde(default)]
215    remove_ask: Vec<String>,
216    #[serde(default)]
217    remove_deny: Vec<String>,
218}
219
220#[derive(Debug, Deserialize, Default)]
221struct GitOverlay {
222    #[serde(default)]
223    replace: bool,
224    #[serde(default)]
225    read_only: Vec<String>,
226    #[serde(default)]
227    allowed_with_config: Vec<String>,
228    config_env: Option<HashMap<String, String>>,
229    #[serde(default)]
230    force_push_flags: Vec<String>,
231    #[serde(default)]
232    remove_read_only: Vec<String>,
233    #[serde(default)]
234    remove_allowed_with_config: Vec<String>,
235    #[serde(default)]
236    remove_force_push_flags: Vec<String>,
237}
238
239#[derive(Debug, Deserialize, Default)]
240struct CargoOverlay {
241    #[serde(default)]
242    replace: bool,
243    #[serde(default)]
244    safe_subcommands: Vec<String>,
245    #[serde(default)]
246    allowed_with_config: Vec<String>,
247    config_env: Option<HashMap<String, String>>,
248    #[serde(default)]
249    remove_safe_subcommands: Vec<String>,
250    #[serde(default)]
251    remove_allowed_with_config: Vec<String>,
252}
253
254#[derive(Debug, Deserialize, Default)]
255struct KubectlOverlay {
256    #[serde(default)]
257    replace: bool,
258    #[serde(default)]
259    read_only: Vec<String>,
260    #[serde(default)]
261    mutating: Vec<String>,
262    #[serde(default)]
263    allowed_with_config: Vec<String>,
264    config_env: Option<HashMap<String, String>>,
265    #[serde(default)]
266    remove_read_only: Vec<String>,
267    #[serde(default)]
268    remove_mutating: Vec<String>,
269    #[serde(default)]
270    remove_allowed_with_config: Vec<String>,
271}
272
273#[derive(Debug, Deserialize, Default)]
274struct GhOverlay {
275    #[serde(default)]
276    replace: bool,
277    #[serde(default)]
278    read_only: Vec<String>,
279    #[serde(default)]
280    mutating: Vec<String>,
281    #[serde(default)]
282    allowed_with_config: Vec<String>,
283    config_env: Option<HashMap<String, String>>,
284    #[serde(default)]
285    remove_read_only: Vec<String>,
286    #[serde(default)]
287    remove_mutating: Vec<String>,
288    #[serde(default)]
289    remove_allowed_with_config: Vec<String>,
290}
291
292// ── Merge logic ──
293
294/// Merge a user list into a default list.
295/// In replace mode: user list replaces default entirely.
296/// In merge mode: remove items first, then extend with additions (deduped).
297fn merge_list(base: &mut Vec<String>, add: Vec<String>, remove: &[String], replace: bool) {
298    if replace {
299        *base = add;
300    } else {
301        base.retain(|item| !remove.contains(item));
302        for item in add {
303            if !base.contains(&item) {
304                base.push(item);
305            }
306        }
307    }
308}
309
310impl Config {
311    /// Load the default embedded configuration.
312    pub fn default_config() -> Self {
313        toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
314    }
315
316    /// Load configuration with resolution order:
317    /// 1. Start with embedded defaults
318    /// 2. Merge user overlay from ~/.config/cc-toolgate/config.toml (if exists)
319    ///
320    /// User config merges with defaults: lists extend, scalars override.
321    /// Set `replace = true` in any section to replace its defaults entirely.
322    /// Use `remove_<field>` lists to subtract specific items from defaults.
323    pub fn load() -> Self {
324        let mut config = Self::default_config();
325        if let Some(overlay) = Self::load_overlay() {
326            config.apply_overlay(overlay);
327        }
328        config
329    }
330
331    /// Try to load user overlay from ~/.config/cc-toolgate/config.toml.
332    fn load_overlay() -> Option<ConfigOverlay> {
333        let home = std::env::var_os("HOME")?;
334        let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
335        let content = std::fs::read_to_string(path).ok()?;
336        match toml::from_str(&content) {
337            Ok(overlay) => Some(overlay),
338            Err(e) => {
339                eprintln!("cc-toolgate: config parse error: {e}");
340                None
341            }
342        }
343    }
344
345    /// Apply an overlay on top of this config (merge semantics).
346    fn apply_overlay(&mut self, overlay: ConfigOverlay) {
347        // Settings: scalar overrides
348        if let Some(v) = overlay.settings.escalate_deny {
349            self.settings.escalate_deny = v;
350        }
351
352        // Commands
353        let c = overlay.commands;
354        merge_list(
355            &mut self.commands.allow,
356            c.allow,
357            &c.remove_allow,
358            c.replace,
359        );
360        merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
361        merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
362
363        // Wrappers
364        let w = overlay.wrappers;
365        merge_list(
366            &mut self.wrappers.allow_floor,
367            w.allow_floor,
368            &w.remove_allow_floor,
369            w.replace,
370        );
371        merge_list(
372            &mut self.wrappers.ask_floor,
373            w.ask_floor,
374            &w.remove_ask_floor,
375            w.replace,
376        );
377
378        // Git
379        let g = overlay.git;
380        merge_list(
381            &mut self.git.read_only,
382            g.read_only,
383            &g.remove_read_only,
384            g.replace,
385        );
386        merge_list(
387            &mut self.git.allowed_with_config,
388            g.allowed_with_config,
389            &g.remove_allowed_with_config,
390            g.replace,
391        );
392        merge_list(
393            &mut self.git.force_push_flags,
394            g.force_push_flags,
395            &g.remove_force_push_flags,
396            g.replace,
397        );
398        if let Some(v) = g.config_env {
399            self.git.config_env = v;
400        }
401
402        // Cargo
403        let ca = overlay.cargo;
404        merge_list(
405            &mut self.cargo.safe_subcommands,
406            ca.safe_subcommands,
407            &ca.remove_safe_subcommands,
408            ca.replace,
409        );
410        merge_list(
411            &mut self.cargo.allowed_with_config,
412            ca.allowed_with_config,
413            &ca.remove_allowed_with_config,
414            ca.replace,
415        );
416        if let Some(v) = ca.config_env {
417            self.cargo.config_env = v;
418        }
419
420        // Kubectl
421        let k = overlay.kubectl;
422        merge_list(
423            &mut self.kubectl.read_only,
424            k.read_only,
425            &k.remove_read_only,
426            k.replace,
427        );
428        merge_list(
429            &mut self.kubectl.mutating,
430            k.mutating,
431            &k.remove_mutating,
432            k.replace,
433        );
434        merge_list(
435            &mut self.kubectl.allowed_with_config,
436            k.allowed_with_config,
437            &k.remove_allowed_with_config,
438            k.replace,
439        );
440        if let Some(v) = k.config_env {
441            self.kubectl.config_env = v;
442        }
443
444        // Gh
445        let gh = overlay.gh;
446        merge_list(
447            &mut self.gh.read_only,
448            gh.read_only,
449            &gh.remove_read_only,
450            gh.replace,
451        );
452        merge_list(
453            &mut self.gh.mutating,
454            gh.mutating,
455            &gh.remove_mutating,
456            gh.replace,
457        );
458        merge_list(
459            &mut self.gh.allowed_with_config,
460            gh.allowed_with_config,
461            &gh.remove_allowed_with_config,
462            gh.replace,
463        );
464        if let Some(v) = gh.config_env {
465            self.gh.config_env = v;
466        }
467    }
468
469    /// Apply an overlay from a TOML string. Used for testing.
470    #[cfg(test)]
471    fn apply_overlay_str(&mut self, toml_str: &str) {
472        let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
473        self.apply_overlay(overlay);
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn default_config_parses() {
483        let config = Config::default_config();
484        assert!(!config.commands.allow.is_empty());
485        assert!(!config.commands.ask.is_empty());
486        assert!(!config.commands.deny.is_empty());
487        assert!(!config.git.read_only.is_empty());
488        assert!(!config.cargo.safe_subcommands.is_empty());
489        assert!(!config.kubectl.read_only.is_empty());
490        assert!(!config.gh.read_only.is_empty());
491    }
492
493    #[test]
494    fn default_config_has_expected_commands() {
495        let config = Config::default_config();
496        assert!(config.commands.allow.contains(&"ls".to_string()));
497        assert!(config.commands.ask.contains(&"rm".to_string()));
498        assert!(config.commands.deny.contains(&"shred".to_string()));
499    }
500
501    #[test]
502    fn default_escalate_deny_is_false() {
503        let config = Config::default_config();
504        assert!(!config.settings.escalate_deny);
505    }
506
507    #[test]
508    fn default_git_env_gate_disabled() {
509        let config = Config::default_config();
510        assert!(config.git.config_env.is_empty());
511        assert!(config.git.allowed_with_config.is_empty());
512    }
513
514    // ── Merge semantics ──
515
516    #[test]
517    fn overlay_extends_allow_list() {
518        let mut config = Config::default_config();
519        config.apply_overlay_str(
520            r#"
521            [commands]
522            allow = ["my-tool"]
523        "#,
524        );
525        // Default allow list still present
526        assert!(config.commands.allow.contains(&"ls".to_string()));
527        // New item added
528        assert!(config.commands.allow.contains(&"my-tool".to_string()));
529    }
530
531    #[test]
532    fn overlay_removes_from_allow_list() {
533        let mut config = Config::default_config();
534        config.apply_overlay_str(
535            r#"
536            [commands]
537            remove_allow = ["cat", "find"]
538        "#,
539        );
540        assert!(!config.commands.allow.contains(&"cat".to_string()));
541        assert!(!config.commands.allow.contains(&"find".to_string()));
542        // Other items still present
543        assert!(config.commands.allow.contains(&"ls".to_string()));
544    }
545
546    #[test]
547    fn default_wrappers_populated() {
548        let config = Config::default_config();
549        assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
550        assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
551        assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
552        assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
553        // These should NOT be in commands.allow/ask anymore
554        assert!(!config.commands.allow.contains(&"xargs".to_string()));
555        assert!(!config.commands.allow.contains(&"env".to_string()));
556        assert!(!config.commands.ask.contains(&"sudo".to_string()));
557    }
558
559    #[test]
560    fn overlay_removes_from_wrappers() {
561        let mut config = Config::default_config();
562        config.apply_overlay_str(
563            r#"
564            [wrappers]
565            remove_allow_floor = ["xargs"]
566        "#,
567        );
568        assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
569        // Others untouched
570        assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
571    }
572
573    #[test]
574    fn overlay_extends_wrappers() {
575        let mut config = Config::default_config();
576        config.apply_overlay_str(
577            r#"
578            [wrappers]
579            allow_floor = ["my-wrapper"]
580        "#,
581        );
582        assert!(
583            config
584                .wrappers
585                .allow_floor
586                .contains(&"my-wrapper".to_string())
587        );
588        assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
589    }
590
591    #[test]
592    fn overlay_replace_commands() {
593        let mut config = Config::default_config();
594        config.apply_overlay_str(
595            r#"
596            [commands]
597            replace = true
598            allow = ["ls", "cat"]
599            ask = ["rm"]
600            deny = ["shred"]
601        "#,
602        );
603        assert_eq!(config.commands.allow, vec!["ls", "cat"]);
604        assert_eq!(config.commands.ask, vec!["rm"]);
605        assert_eq!(config.commands.deny, vec!["shred"]);
606    }
607
608    #[test]
609    fn overlay_git_env_gate() {
610        let mut config = Config::default_config();
611        config.apply_overlay_str(
612            r#"
613            [git]
614            allowed_with_config = ["commit", "add", "push"]
615            [git.config_env]
616            GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
617        "#,
618        );
619        assert_eq!(
620            config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
621            "~/.gitconfig.ai"
622        );
623        assert_eq!(
624            config.git.allowed_with_config,
625            vec!["commit", "add", "push"]
626        );
627        // Default read_only still present
628        assert!(config.git.read_only.contains(&"status".to_string()));
629        assert!(config.git.read_only.contains(&"log".to_string()));
630    }
631
632    #[test]
633    fn overlay_escalate_deny() {
634        let mut config = Config::default_config();
635        config.apply_overlay_str(
636            r#"
637            [settings]
638            escalate_deny = true
639        "#,
640        );
641        assert!(config.settings.escalate_deny);
642    }
643
644    #[test]
645    fn overlay_omitted_settings_unchanged() {
646        let mut config = Config::default_config();
647        config.apply_overlay_str(
648            r#"
649            [commands]
650            allow = ["my-tool"]
651        "#,
652        );
653        // Settings not in overlay remain at defaults
654        assert!(!config.settings.escalate_deny);
655    }
656
657    #[test]
658    fn overlay_no_duplicates() {
659        let mut config = Config::default_config();
660        config.apply_overlay_str(
661            r#"
662            [commands]
663            allow = ["ls"]
664        "#,
665        );
666        let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
667        assert_eq!(count, 1);
668    }
669
670    #[test]
671    fn overlay_remove_and_add() {
672        let mut config = Config::default_config();
673        // Move "eval" from deny to ask
674        config.apply_overlay_str(
675            r#"
676            [commands]
677            remove_deny = ["eval"]
678            ask = ["eval"]
679        "#,
680        );
681        assert!(!config.commands.deny.contains(&"eval".to_string()));
682        assert!(config.commands.ask.contains(&"eval".to_string()));
683    }
684
685    #[test]
686    fn overlay_replace_git() {
687        let mut config = Config::default_config();
688        config.apply_overlay_str(
689            r#"
690            [git]
691            replace = true
692            read_only = ["status", "log"]
693            force_push_flags = ["--force"]
694        "#,
695        );
696        assert_eq!(config.git.read_only, vec!["status", "log"]);
697        assert_eq!(config.git.force_push_flags, vec!["--force"]);
698        assert!(config.git.allowed_with_config.is_empty());
699    }
700
701    #[test]
702    fn overlay_unrelated_sections_untouched() {
703        let mut config = Config::default_config();
704        let original_kubectl_read_only = config.kubectl.read_only.clone();
705        config.apply_overlay_str(
706            r#"
707            [git]
708            allowed_with_config = ["push"]
709            config_env_var = "GIT_CONFIG_GLOBAL"
710        "#,
711        );
712        assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
713    }
714
715    #[test]
716    fn empty_overlay_changes_nothing() {
717        let original = Config::default_config();
718        let mut config = Config::default_config();
719        config.apply_overlay_str("");
720        assert_eq!(config.commands.allow.len(), original.commands.allow.len());
721        assert_eq!(config.git.read_only.len(), original.git.read_only.len());
722    }
723}