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`. Overlays merge on top in this order (later wins):
5//!
6//! 1. Embedded defaults.
7//! 2. User overlay at `~/.config/cc-toolgate/config.toml`.
8//! 3. Project overlay at `<git-root>/.claude/cc-toolgate.toml` (if CWD is
9//!    inside a git repo). Lets a project permit extra commands without
10//!    loosening user-global rules.
11//!
12//! User overlays use full merge semantics: lists extend (deduplicated),
13//! scalars override, `remove_<field>` subtracts, and `replace = true`
14//! replaces entirely. Project overlays are restricted to additive
15//! operations only — `replace` and `remove_*` fields are stripped for
16//! security (a repo should not be able to weaken user-global rules).
17
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21/// Embedded default configuration (compiled into the binary from `config.default.toml`).
22const DEFAULT_CONFIG: &str = include_str!("../config.default.toml");
23
24// ── Final (merged) config types ──
25
26/// Top-level configuration, produced by merging embedded defaults with
27/// an optional user overlay from `~/.config/cc-toolgate/config.toml`.
28#[derive(Debug, Deserialize, Serialize)]
29pub struct Config {
30    /// Global settings (e.g. escalate_deny).
31    #[serde(default)]
32    pub settings: Settings,
33    /// Flat command-to-decision mappings (allow, ask, deny lists).
34    #[serde(default)]
35    pub commands: Commands,
36    /// Wrapper commands that execute their arguments as subcommands.
37    #[serde(default)]
38    pub wrappers: WrapperConfig,
39    /// Git subcommand-aware evaluation rules.
40    #[serde(default)]
41    pub git: GitConfig,
42    /// Cargo subcommand-aware evaluation rules.
43    #[serde(default)]
44    pub cargo: CargoConfig,
45    /// kubectl subcommand-aware evaluation rules.
46    #[serde(default)]
47    pub kubectl: KubectlConfig,
48    /// GitHub CLI (gh) subcommand-aware evaluation rules.
49    #[serde(default)]
50    pub gh: GhConfig,
51    /// Path to the project overlay file, if one was loaded.
52    /// Set by [`Config::load()`] when a project-level `.claude/cc-toolgate.toml`
53    /// is found. Used to annotate ASK decisions with provenance.
54    #[serde(skip)]
55    pub project_overlay_path: Option<std::path::PathBuf>,
56}
57
58/// Global settings that affect evaluation behavior.
59#[derive(Debug, Deserialize, Serialize, Default)]
60pub struct Settings {
61    /// When true, DENY decisions are escalated to ASK (the user is prompted
62    /// instead of being blocked). Useful for operators who want visibility
63    /// without hard blocks.
64    #[serde(default)]
65    pub escalate_deny: bool,
66}
67
68/// Flat command name → decision mappings for simple commands.
69///
70/// Commands in `allow` run silently, `ask` prompts the user, `deny` blocks outright.
71/// Unrecognized commands default to ASK.
72#[derive(Debug, Deserialize, Serialize, Default)]
73pub struct Commands {
74    /// Commands that run silently (e.g. `ls`, `cat`, `grep`).
75    #[serde(default)]
76    pub allow: Vec<String>,
77    /// Commands that require user confirmation (e.g. `rm`, `curl`, `pip`).
78    #[serde(default)]
79    pub ask: Vec<String>,
80    /// Commands that are blocked outright (e.g. `shred`, `dd`, `mkfs`).
81    #[serde(default)]
82    pub deny: Vec<String>,
83}
84
85/// Commands that execute their arguments as subcommands.
86/// The wrapped command is extracted and evaluated; the final decision
87/// is max(floor, wrapped_command_decision).
88#[derive(Debug, Deserialize, Serialize, Default)]
89pub struct WrapperConfig {
90    /// Wrappers with Allow floor: wrapper is safe, wrapped command determines disposition.
91    /// e.g. xargs, parallel, env, nohup, nice, timeout, time, watch
92    #[serde(default)]
93    pub allow_floor: Vec<String>,
94    /// Wrappers with Ask floor: always at least Ask, wrapped command can escalate to Deny.
95    /// e.g. sudo, doas, pkexec
96    #[serde(default)]
97    pub ask_floor: Vec<String>,
98}
99
100/// Git subcommand evaluation rules.
101#[derive(Debug, Deserialize, Serialize, Default)]
102pub struct GitConfig {
103    /// Subcommands that are always allowed (e.g. `status`, `log`, `diff`, `branch`).
104    #[serde(default)]
105    pub read_only: Vec<String>,
106    /// Subcommands that are allowed only when all `config_env` entries match
107    /// (e.g. `push`, `pull` when `GIT_CONFIG_GLOBAL=~/.gitconfig.ai`).
108    #[serde(default)]
109    pub allowed_with_config: Vec<String>,
110    /// Environment variable requirements for `allowed_with_config` subcommands.
111    /// Each entry maps a var name to its required value. All must match (AND).
112    /// Checked in the command's inline env first, then the process environment.
113    /// When empty, the env-gating feature is disabled and those commands always ASK.
114    #[serde(default)]
115    pub config_env: HashMap<String, String>,
116    /// Flags that indicate a force-push (e.g. `--force`, `-f`, `--force-with-lease`).
117    /// Force-pushes always require confirmation regardless of env-gating.
118    #[serde(default)]
119    pub force_push_flags: Vec<String>,
120}
121
122/// Cargo subcommand evaluation rules.
123#[derive(Debug, Deserialize, Serialize, Default)]
124pub struct CargoConfig {
125    /// Subcommands that are always allowed (e.g. `build`, `test`, `check`, `clippy`).
126    #[serde(default)]
127    pub safe_subcommands: Vec<String>,
128    /// Subcommands allowed only when all `config_env` entries match.
129    #[serde(default)]
130    pub allowed_with_config: Vec<String>,
131    /// Environment variable requirements for `allowed_with_config` subcommands.
132    #[serde(default)]
133    pub config_env: HashMap<String, String>,
134}
135
136/// kubectl subcommand evaluation rules.
137#[derive(Debug, Deserialize, Serialize, Default)]
138pub struct KubectlConfig {
139    /// Read-only subcommands that are always allowed (e.g. `get`, `describe`, `logs`).
140    #[serde(default)]
141    pub read_only: Vec<String>,
142    /// Known mutating subcommands that always require confirmation (e.g. `apply`, `delete`).
143    #[serde(default)]
144    pub mutating: Vec<String>,
145    /// Subcommands allowed only when all `config_env` entries match.
146    #[serde(default)]
147    pub allowed_with_config: Vec<String>,
148    /// Environment variable requirements for `allowed_with_config` subcommands.
149    #[serde(default)]
150    pub config_env: HashMap<String, String>,
151}
152
153/// GitHub CLI (gh) subcommand evaluation rules.
154///
155/// gh uses two-word subcommands (e.g. `pr list`, `issue create`), so
156/// both two-word and one-word matches are checked.
157#[derive(Debug, Deserialize, Serialize, Default)]
158pub struct GhConfig {
159    /// Read-only subcommands (e.g. `pr list`, `pr view`, `status`, `api`).
160    #[serde(default)]
161    pub read_only: Vec<String>,
162    /// Known mutating subcommands (e.g. `pr create`, `pr merge`, `repo delete`).
163    #[serde(default)]
164    pub mutating: Vec<String>,
165    /// Subcommands allowed only when all `config_env` entries match.
166    #[serde(default)]
167    pub allowed_with_config: Vec<String>,
168    /// Environment variable requirements for `allowed_with_config` subcommands.
169    #[serde(default)]
170    pub config_env: HashMap<String, String>,
171}
172
173// ── Overlay types (user config that merges with defaults) ──
174//
175// These mirror the public config types but use `Option` for scalars and
176// include `replace` flags and `remove_*` lists for the merge system.
177
178/// User-provided configuration overlay, deserialized from `~/.config/cc-toolgate/config.toml`.
179#[derive(Debug, Deserialize, Default)]
180struct ConfigOverlay {
181    #[serde(default)]
182    settings: SettingsOverlay,
183    #[serde(default)]
184    commands: CommandsOverlay,
185    #[serde(default)]
186    wrappers: WrappersOverlay,
187    #[serde(default)]
188    git: GitOverlay,
189    #[serde(default)]
190    cargo: CargoOverlay,
191    #[serde(default)]
192    kubectl: KubectlOverlay,
193    #[serde(default)]
194    gh: GhOverlay,
195}
196
197#[derive(Debug, Deserialize, Default)]
198struct SettingsOverlay {
199    escalate_deny: Option<bool>,
200}
201
202#[derive(Debug, Deserialize, Default)]
203struct WrappersOverlay {
204    #[serde(default)]
205    replace: bool,
206    #[serde(default)]
207    allow_floor: Vec<String>,
208    #[serde(default)]
209    ask_floor: Vec<String>,
210    #[serde(default)]
211    remove_allow_floor: Vec<String>,
212    #[serde(default)]
213    remove_ask_floor: Vec<String>,
214}
215
216#[derive(Debug, Deserialize, Default)]
217struct CommandsOverlay {
218    #[serde(default)]
219    replace: bool,
220    #[serde(default)]
221    allow: Vec<String>,
222    #[serde(default)]
223    ask: Vec<String>,
224    #[serde(default)]
225    deny: Vec<String>,
226    #[serde(default)]
227    remove_allow: Vec<String>,
228    #[serde(default)]
229    remove_ask: Vec<String>,
230    #[serde(default)]
231    remove_deny: Vec<String>,
232}
233
234#[derive(Debug, Deserialize, Default)]
235struct GitOverlay {
236    #[serde(default)]
237    replace: bool,
238    #[serde(default)]
239    read_only: Vec<String>,
240    #[serde(default)]
241    allowed_with_config: Vec<String>,
242    config_env: Option<HashMap<String, String>>,
243    #[serde(default)]
244    force_push_flags: Vec<String>,
245    #[serde(default)]
246    remove_read_only: Vec<String>,
247    #[serde(default)]
248    remove_allowed_with_config: Vec<String>,
249    #[serde(default)]
250    remove_force_push_flags: Vec<String>,
251}
252
253#[derive(Debug, Deserialize, Default)]
254struct CargoOverlay {
255    #[serde(default)]
256    replace: bool,
257    #[serde(default)]
258    safe_subcommands: Vec<String>,
259    #[serde(default)]
260    allowed_with_config: Vec<String>,
261    config_env: Option<HashMap<String, String>>,
262    #[serde(default)]
263    remove_safe_subcommands: Vec<String>,
264    #[serde(default)]
265    remove_allowed_with_config: Vec<String>,
266}
267
268#[derive(Debug, Deserialize, Default)]
269struct KubectlOverlay {
270    #[serde(default)]
271    replace: bool,
272    #[serde(default)]
273    read_only: Vec<String>,
274    #[serde(default)]
275    mutating: Vec<String>,
276    #[serde(default)]
277    allowed_with_config: Vec<String>,
278    config_env: Option<HashMap<String, String>>,
279    #[serde(default)]
280    remove_read_only: Vec<String>,
281    #[serde(default)]
282    remove_mutating: Vec<String>,
283    #[serde(default)]
284    remove_allowed_with_config: Vec<String>,
285}
286
287#[derive(Debug, Deserialize, Default)]
288struct GhOverlay {
289    #[serde(default)]
290    replace: bool,
291    #[serde(default)]
292    read_only: Vec<String>,
293    #[serde(default)]
294    mutating: Vec<String>,
295    #[serde(default)]
296    allowed_with_config: Vec<String>,
297    config_env: Option<HashMap<String, String>>,
298    #[serde(default)]
299    remove_read_only: Vec<String>,
300    #[serde(default)]
301    remove_mutating: Vec<String>,
302    #[serde(default)]
303    remove_allowed_with_config: Vec<String>,
304}
305
306// ── Merge logic ──
307
308/// Merge a user list into a default list.
309/// In replace mode: user list replaces default entirely.
310/// In merge mode: remove items first, then extend with additions (deduped).
311fn merge_list(base: &mut Vec<String>, add: Vec<String>, remove: &[String], replace: bool) {
312    if replace {
313        *base = add;
314    } else {
315        base.retain(|item| !remove.contains(item));
316        for item in add {
317            if !base.contains(&item) {
318                base.push(item);
319            }
320        }
321    }
322}
323
324impl Config {
325    /// Load the default embedded configuration.
326    pub fn default_config() -> Self {
327        toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
328    }
329
330    /// Load configuration with resolution order:
331    /// 1. Start with embedded defaults
332    /// 2. Merge user overlay from ~/.config/cc-toolgate/config.toml (if exists)
333    /// 3. Merge project overlay from <git-root>/.claude/cc-toolgate.toml
334    ///    (if CWD is inside a git repo and the file exists)
335    ///
336    /// Each overlay merges with what's below it: lists extend, scalars override.
337    /// Set `replace = true` in any section to replace its defaults entirely.
338    /// Use `remove_<field>` lists to subtract specific items.
339    ///
340    /// When a project overlay is loaded, `project_overlay_path` is set to the
341    /// file that was applied. Callers can inspect this to annotate decisions
342    /// with provenance information.
343    pub fn load() -> Self {
344        let mut config = Self::default_config();
345        if let Some(overlay) = Self::load_overlay() {
346            config.apply_overlay(overlay);
347        }
348        if let Some((overlay, path)) = Self::load_project_overlay() {
349            config.apply_overlay(overlay);
350            config.project_overlay_path = Some(path);
351        }
352        config
353    }
354
355    /// Try to load user overlay from ~/.config/cc-toolgate/config.toml.
356    fn load_overlay() -> Option<ConfigOverlay> {
357        let home = std::env::var_os("HOME")?;
358        let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
359        load_overlay_from_path(&path, "config parse error")
360    }
361
362    /// Try to load project overlay from <git-root>/.claude/cc-toolgate.toml.
363    /// Returns both the parsed overlay and the path it was loaded from.
364    ///
365    /// Project overlays may only ADD to allow/ask/deny lists. Any `replace` flags
366    /// or `remove_*` lists are stripped and a warning is emitted. This prevents a
367    /// malicious project config from removing safety rules set at the user level.
368    fn load_project_overlay() -> Option<(ConfigOverlay, std::path::PathBuf)> {
369        let cwd = std::env::current_dir().ok()?;
370        let git_root = find_git_root(&cwd)?;
371        let path = git_root.join(".claude/cc-toolgate.toml");
372        let mut overlay = load_overlay_from_path(&path, "project config parse error")?;
373        strip_project_overlay_dangerous_fields(&mut overlay, &path);
374        Some((overlay, path))
375    }
376
377    /// Apply an overlay on top of this config (merge semantics).
378    fn apply_overlay(&mut self, overlay: ConfigOverlay) {
379        // Settings: scalar overrides
380        if let Some(v) = overlay.settings.escalate_deny {
381            self.settings.escalate_deny = v;
382        }
383
384        // Commands
385        let c = overlay.commands;
386        merge_list(
387            &mut self.commands.allow,
388            c.allow,
389            &c.remove_allow,
390            c.replace,
391        );
392        merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
393        merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
394
395        // Wrappers
396        let w = overlay.wrappers;
397        merge_list(
398            &mut self.wrappers.allow_floor,
399            w.allow_floor,
400            &w.remove_allow_floor,
401            w.replace,
402        );
403        merge_list(
404            &mut self.wrappers.ask_floor,
405            w.ask_floor,
406            &w.remove_ask_floor,
407            w.replace,
408        );
409
410        // Git
411        let g = overlay.git;
412        merge_list(
413            &mut self.git.read_only,
414            g.read_only,
415            &g.remove_read_only,
416            g.replace,
417        );
418        merge_list(
419            &mut self.git.allowed_with_config,
420            g.allowed_with_config,
421            &g.remove_allowed_with_config,
422            g.replace,
423        );
424        merge_list(
425            &mut self.git.force_push_flags,
426            g.force_push_flags,
427            &g.remove_force_push_flags,
428            g.replace,
429        );
430        if let Some(v) = g.config_env {
431            self.git.config_env = v;
432        }
433
434        // Cargo
435        let ca = overlay.cargo;
436        merge_list(
437            &mut self.cargo.safe_subcommands,
438            ca.safe_subcommands,
439            &ca.remove_safe_subcommands,
440            ca.replace,
441        );
442        merge_list(
443            &mut self.cargo.allowed_with_config,
444            ca.allowed_with_config,
445            &ca.remove_allowed_with_config,
446            ca.replace,
447        );
448        if let Some(v) = ca.config_env {
449            self.cargo.config_env = v;
450        }
451
452        // Kubectl
453        let k = overlay.kubectl;
454        merge_list(
455            &mut self.kubectl.read_only,
456            k.read_only,
457            &k.remove_read_only,
458            k.replace,
459        );
460        merge_list(
461            &mut self.kubectl.mutating,
462            k.mutating,
463            &k.remove_mutating,
464            k.replace,
465        );
466        merge_list(
467            &mut self.kubectl.allowed_with_config,
468            k.allowed_with_config,
469            &k.remove_allowed_with_config,
470            k.replace,
471        );
472        if let Some(v) = k.config_env {
473            self.kubectl.config_env = v;
474        }
475
476        // Gh
477        let gh = overlay.gh;
478        merge_list(
479            &mut self.gh.read_only,
480            gh.read_only,
481            &gh.remove_read_only,
482            gh.replace,
483        );
484        merge_list(
485            &mut self.gh.mutating,
486            gh.mutating,
487            &gh.remove_mutating,
488            gh.replace,
489        );
490        merge_list(
491            &mut self.gh.allowed_with_config,
492            gh.allowed_with_config,
493            &gh.remove_allowed_with_config,
494            gh.replace,
495        );
496        if let Some(v) = gh.config_env {
497            self.gh.config_env = v;
498        }
499    }
500
501    /// Apply an overlay from a TOML string. Used for testing.
502    #[cfg(test)]
503    fn apply_overlay_str(&mut self, toml_str: &str) {
504        let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
505        self.apply_overlay(overlay);
506    }
507}
508
509/// Strip `replace` flags and `remove_*` lists from a project overlay.
510///
511/// Project overlays are untrusted: they live in the repo and could be crafted
512/// by a malicious project to remove safety rules the user relies on. This
513/// function enforces the invariant that project overlays can only ADD entries,
514/// never remove or replace them. If any dangerous field was non-empty/true, a
515/// warning is printed to stderr.
516fn strip_project_overlay_dangerous_fields(overlay: &mut ConfigOverlay, path: &std::path::Path) {
517    let mut stripped = false;
518
519    // commands
520    if overlay.commands.replace
521        || !overlay.commands.remove_allow.is_empty()
522        || !overlay.commands.remove_ask.is_empty()
523        || !overlay.commands.remove_deny.is_empty()
524    {
525        stripped = true;
526    }
527    overlay.commands.replace = false;
528    overlay.commands.remove_allow.clear();
529    overlay.commands.remove_ask.clear();
530    overlay.commands.remove_deny.clear();
531
532    // wrappers
533    if overlay.wrappers.replace
534        || !overlay.wrappers.remove_allow_floor.is_empty()
535        || !overlay.wrappers.remove_ask_floor.is_empty()
536    {
537        stripped = true;
538    }
539    overlay.wrappers.replace = false;
540    overlay.wrappers.remove_allow_floor.clear();
541    overlay.wrappers.remove_ask_floor.clear();
542
543    // git
544    if overlay.git.replace
545        || !overlay.git.remove_read_only.is_empty()
546        || !overlay.git.remove_allowed_with_config.is_empty()
547        || !overlay.git.remove_force_push_flags.is_empty()
548    {
549        stripped = true;
550    }
551    overlay.git.replace = false;
552    overlay.git.remove_read_only.clear();
553    overlay.git.remove_allowed_with_config.clear();
554    overlay.git.remove_force_push_flags.clear();
555
556    // cargo
557    if overlay.cargo.replace
558        || !overlay.cargo.remove_safe_subcommands.is_empty()
559        || !overlay.cargo.remove_allowed_with_config.is_empty()
560    {
561        stripped = true;
562    }
563    overlay.cargo.replace = false;
564    overlay.cargo.remove_safe_subcommands.clear();
565    overlay.cargo.remove_allowed_with_config.clear();
566
567    // kubectl
568    if overlay.kubectl.replace
569        || !overlay.kubectl.remove_read_only.is_empty()
570        || !overlay.kubectl.remove_mutating.is_empty()
571        || !overlay.kubectl.remove_allowed_with_config.is_empty()
572    {
573        stripped = true;
574    }
575    overlay.kubectl.replace = false;
576    overlay.kubectl.remove_read_only.clear();
577    overlay.kubectl.remove_mutating.clear();
578    overlay.kubectl.remove_allowed_with_config.clear();
579
580    // gh
581    if overlay.gh.replace
582        || !overlay.gh.remove_read_only.is_empty()
583        || !overlay.gh.remove_mutating.is_empty()
584        || !overlay.gh.remove_allowed_with_config.is_empty()
585    {
586        stripped = true;
587    }
588    overlay.gh.replace = false;
589    overlay.gh.remove_read_only.clear();
590    overlay.gh.remove_mutating.clear();
591    overlay.gh.remove_allowed_with_config.clear();
592
593    if stripped {
594        eprintln!(
595            "cc-toolgate: project overlay at {} attempted to use replace/remove — stripped for security",
596            path.display()
597        );
598    }
599}
600
601/// Read and parse a ConfigOverlay from `path`. Returns `None` if the file
602/// can't be read (missing, permission denied, etc.); logs to stderr and
603/// returns `None` on parse errors.
604fn load_overlay_from_path(path: &std::path::Path, err_label: &str) -> Option<ConfigOverlay> {
605    let content = std::fs::read_to_string(path).ok()?;
606    match toml::from_str(&content) {
607        Ok(overlay) => Some(overlay),
608        Err(e) => {
609            eprintln!("cc-toolgate: {err_label}: {e}");
610            None
611        }
612    }
613}
614
615/// Walk up from `start` looking for a `.git` entry (dir for normal repos,
616/// file for worktrees). Returns the containing directory, or `None` if no
617/// ancestor contains `.git`.
618fn find_git_root(start: &std::path::Path) -> Option<std::path::PathBuf> {
619    let mut current = Some(start);
620    while let Some(dir) = current {
621        if dir.join(".git").exists() {
622            return Some(dir.to_path_buf());
623        }
624        current = dir.parent();
625    }
626    None
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn default_config_parses() {
635        let config = Config::default_config();
636        assert!(!config.commands.allow.is_empty());
637        assert!(!config.commands.ask.is_empty());
638        assert!(!config.commands.deny.is_empty());
639        assert!(!config.git.read_only.is_empty());
640        assert!(!config.cargo.safe_subcommands.is_empty());
641        assert!(!config.kubectl.read_only.is_empty());
642        assert!(!config.gh.read_only.is_empty());
643    }
644
645    #[test]
646    fn default_config_has_expected_commands() {
647        let config = Config::default_config();
648        assert!(config.commands.allow.contains(&"ls".to_string()));
649        assert!(config.commands.ask.contains(&"rm".to_string()));
650        assert!(config.commands.deny.contains(&"shred".to_string()));
651    }
652
653    #[test]
654    fn default_escalate_deny_is_false() {
655        let config = Config::default_config();
656        assert!(!config.settings.escalate_deny);
657    }
658
659    #[test]
660    fn default_git_env_gate_disabled() {
661        let config = Config::default_config();
662        assert!(config.git.config_env.is_empty());
663        assert!(config.git.allowed_with_config.is_empty());
664    }
665
666    // ── Merge semantics ──
667
668    #[test]
669    fn overlay_extends_allow_list() {
670        let mut config = Config::default_config();
671        config.apply_overlay_str(
672            r#"
673            [commands]
674            allow = ["my-tool"]
675        "#,
676        );
677        // Default allow list still present
678        assert!(config.commands.allow.contains(&"ls".to_string()));
679        // New item added
680        assert!(config.commands.allow.contains(&"my-tool".to_string()));
681    }
682
683    #[test]
684    fn overlay_removes_from_allow_list() {
685        let mut config = Config::default_config();
686        config.apply_overlay_str(
687            r#"
688            [commands]
689            remove_allow = ["cat", "find"]
690        "#,
691        );
692        assert!(!config.commands.allow.contains(&"cat".to_string()));
693        assert!(!config.commands.allow.contains(&"find".to_string()));
694        // Other items still present
695        assert!(config.commands.allow.contains(&"ls".to_string()));
696    }
697
698    #[test]
699    fn default_wrappers_populated() {
700        let config = Config::default_config();
701        assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
702        assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
703        assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
704        assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
705        // These should NOT be in commands.allow/ask anymore
706        assert!(!config.commands.allow.contains(&"xargs".to_string()));
707        assert!(!config.commands.allow.contains(&"env".to_string()));
708        assert!(!config.commands.ask.contains(&"sudo".to_string()));
709    }
710
711    #[test]
712    fn overlay_removes_from_wrappers() {
713        let mut config = Config::default_config();
714        config.apply_overlay_str(
715            r#"
716            [wrappers]
717            remove_allow_floor = ["xargs"]
718        "#,
719        );
720        assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
721        // Others untouched
722        assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
723    }
724
725    #[test]
726    fn overlay_extends_wrappers() {
727        let mut config = Config::default_config();
728        config.apply_overlay_str(
729            r#"
730            [wrappers]
731            allow_floor = ["my-wrapper"]
732        "#,
733        );
734        assert!(
735            config
736                .wrappers
737                .allow_floor
738                .contains(&"my-wrapper".to_string())
739        );
740        assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
741    }
742
743    #[test]
744    fn overlay_replace_commands() {
745        let mut config = Config::default_config();
746        config.apply_overlay_str(
747            r#"
748            [commands]
749            replace = true
750            allow = ["ls", "cat"]
751            ask = ["rm"]
752            deny = ["shred"]
753        "#,
754        );
755        assert_eq!(config.commands.allow, vec!["ls", "cat"]);
756        assert_eq!(config.commands.ask, vec!["rm"]);
757        assert_eq!(config.commands.deny, vec!["shred"]);
758    }
759
760    #[test]
761    fn overlay_git_env_gate() {
762        let mut config = Config::default_config();
763        config.apply_overlay_str(
764            r#"
765            [git]
766            allowed_with_config = ["commit", "add", "push"]
767            [git.config_env]
768            GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
769        "#,
770        );
771        assert_eq!(
772            config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
773            "~/.gitconfig.ai"
774        );
775        assert_eq!(
776            config.git.allowed_with_config,
777            vec!["commit", "add", "push"]
778        );
779        // Default read_only still present
780        assert!(config.git.read_only.contains(&"status".to_string()));
781        assert!(config.git.read_only.contains(&"log".to_string()));
782    }
783
784    #[test]
785    fn overlay_escalate_deny() {
786        let mut config = Config::default_config();
787        config.apply_overlay_str(
788            r#"
789            [settings]
790            escalate_deny = true
791        "#,
792        );
793        assert!(config.settings.escalate_deny);
794    }
795
796    #[test]
797    fn overlay_omitted_settings_unchanged() {
798        let mut config = Config::default_config();
799        config.apply_overlay_str(
800            r#"
801            [commands]
802            allow = ["my-tool"]
803        "#,
804        );
805        // Settings not in overlay remain at defaults
806        assert!(!config.settings.escalate_deny);
807    }
808
809    #[test]
810    fn overlay_no_duplicates() {
811        let mut config = Config::default_config();
812        config.apply_overlay_str(
813            r#"
814            [commands]
815            allow = ["ls"]
816        "#,
817        );
818        let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
819        assert_eq!(count, 1);
820    }
821
822    #[test]
823    fn overlay_remove_and_add() {
824        let mut config = Config::default_config();
825        // Move "eval" from deny to ask
826        config.apply_overlay_str(
827            r#"
828            [commands]
829            remove_deny = ["eval"]
830            ask = ["eval"]
831        "#,
832        );
833        assert!(!config.commands.deny.contains(&"eval".to_string()));
834        assert!(config.commands.ask.contains(&"eval".to_string()));
835    }
836
837    #[test]
838    fn overlay_replace_git() {
839        let mut config = Config::default_config();
840        config.apply_overlay_str(
841            r#"
842            [git]
843            replace = true
844            read_only = ["status", "log"]
845            force_push_flags = ["--force"]
846        "#,
847        );
848        assert_eq!(config.git.read_only, vec!["status", "log"]);
849        assert_eq!(config.git.force_push_flags, vec!["--force"]);
850        assert!(config.git.allowed_with_config.is_empty());
851    }
852
853    #[test]
854    fn overlay_unrelated_sections_untouched() {
855        let mut config = Config::default_config();
856        let original_kubectl_read_only = config.kubectl.read_only.clone();
857        config.apply_overlay_str(
858            r#"
859            [git]
860            allowed_with_config = ["push"]
861            config_env_var = "GIT_CONFIG_GLOBAL"
862        "#,
863        );
864        assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
865    }
866
867    #[test]
868    fn empty_overlay_changes_nothing() {
869        let original = Config::default_config();
870        let mut config = Config::default_config();
871        config.apply_overlay_str("");
872        assert_eq!(config.commands.allow.len(), original.commands.allow.len());
873        assert_eq!(config.git.read_only.len(), original.git.read_only.len());
874    }
875
876    // ── Project overlay discovery ──
877
878    /// Make a scratch dir under std::env::temp_dir() unique to this test run.
879    fn scratch_dir(tag: &str) -> std::path::PathBuf {
880        let nanos = std::time::SystemTime::now()
881            .duration_since(std::time::UNIX_EPOCH)
882            .unwrap()
883            .as_nanos();
884        let dir = std::env::temp_dir().join(format!("cc-toolgate-test-{tag}-{nanos}"));
885        std::fs::create_dir_all(&dir).unwrap();
886        dir
887    }
888
889    #[test]
890    fn find_git_root_finds_dot_git_in_ancestor() {
891        let root = scratch_dir("find-root");
892        std::fs::create_dir(root.join(".git")).unwrap();
893        let deep = root.join("a/b/c");
894        std::fs::create_dir_all(&deep).unwrap();
895
896        assert_eq!(find_git_root(&deep), Some(root.clone()));
897        assert_eq!(find_git_root(&root), Some(root.clone()));
898
899        std::fs::remove_dir_all(&root).ok();
900    }
901
902    #[test]
903    fn project_overlay_file_parses_and_extends_allow() {
904        let root = scratch_dir("project-overlay");
905        std::fs::create_dir(root.join(".git")).unwrap();
906        std::fs::create_dir(root.join(".claude")).unwrap();
907        std::fs::write(
908            root.join(".claude/cc-toolgate.toml"),
909            r#"
910            [commands]
911            allow = ["my-project-tool"]
912            "#,
913        )
914        .unwrap();
915
916        let path = root.join(".claude/cc-toolgate.toml");
917        let overlay = load_overlay_from_path(&path, "test").expect("parses");
918
919        let mut config = Config::default_config();
920        config.apply_overlay(overlay);
921        assert!(
922            config
923                .commands
924                .allow
925                .contains(&"my-project-tool".to_string())
926        );
927
928        std::fs::remove_dir_all(&root).ok();
929    }
930
931    // ── Config::load() integration tests ──
932    //
933    // These tests change the process CWD and HOME, so they require nextest
934    // (which runs each test in its own process) for isolation safety.
935
936    /// Assert we are running under nextest (process-per-test isolation).
937    fn require_nextest() {
938        assert!(
939            std::env::var("NEXTEST").is_ok(),
940            "this test mutates process CWD/HOME and requires nextest (cargo nextest run)"
941        );
942    }
943
944    #[test]
945    fn config_load_applies_project_overlay() {
946        require_nextest();
947
948        let root = scratch_dir("load-project");
949        std::fs::create_dir(root.join(".git")).unwrap();
950        std::fs::create_dir(root.join(".claude")).unwrap();
951        std::fs::write(
952            root.join(".claude/cc-toolgate.toml"),
953            r#"
954            [commands]
955            allow = ["my-test-script"]
956            "#,
957        )
958        .unwrap();
959
960        // Point HOME to a nonexistent dir so no user overlay interferes.
961        let fake_home = root.join("fakehome");
962        std::fs::create_dir_all(&fake_home).unwrap();
963        unsafe { std::env::set_var("HOME", &fake_home) };
964
965        // Change CWD so Config::load() discovers the project overlay.
966        let original_dir = std::env::current_dir().unwrap();
967        std::env::set_current_dir(&root).unwrap();
968
969        let config = Config::load();
970
971        // Restore CWD before any assertions (in case they panic).
972        std::env::set_current_dir(&original_dir).unwrap();
973
974        // Default commands are still present.
975        assert!(
976            config.commands.allow.contains(&"ls".to_string()),
977            "default 'ls' should still be in allow list"
978        );
979        // Project overlay's addition is present.
980        assert!(
981            config
982                .commands
983                .allow
984                .contains(&"my-test-script".to_string()),
985            "project overlay should have added 'my-test-script' to allow list"
986        );
987        // project_overlay_path should be set.
988        let expected_path = root.join(".claude/cc-toolgate.toml");
989        assert_eq!(
990            config.project_overlay_path,
991            Some(expected_path),
992            "project_overlay_path should record the overlay file"
993        );
994
995        std::fs::remove_dir_all(&root).ok();
996    }
997
998    #[test]
999    fn config_load_project_overlay_replace_is_stripped() {
1000        require_nextest();
1001
1002        let root = scratch_dir("load-replace-stripped");
1003        std::fs::create_dir(root.join(".git")).unwrap();
1004        std::fs::create_dir(root.join(".claude")).unwrap();
1005        // A project overlay with replace = true — the replace flag must be
1006        // stripped for security. The additive items in the overlay are still
1007        // applied, but the default allow list is NOT discarded.
1008        std::fs::write(
1009            root.join(".claude/cc-toolgate.toml"),
1010            r#"
1011            [commands]
1012            replace = true
1013            allow = ["only-this"]
1014            ask = ["only-ask"]
1015            deny = ["only-deny"]
1016            "#,
1017        )
1018        .unwrap();
1019
1020        let fake_home = root.join("fakehome");
1021        std::fs::create_dir_all(&fake_home).unwrap();
1022        unsafe { std::env::set_var("HOME", &fake_home) };
1023
1024        let original_dir = std::env::current_dir().unwrap();
1025        std::env::set_current_dir(&root).unwrap();
1026
1027        let config = Config::load();
1028
1029        std::env::set_current_dir(&original_dir).unwrap();
1030
1031        // replace = true is stripped: the default allow list is preserved.
1032        assert!(
1033            config.commands.allow.contains(&"ls".to_string()),
1034            "replace should be stripped; default 'ls' must still be in allow list"
1035        );
1036        // The additive items from the overlay are still applied.
1037        assert!(
1038            config.commands.allow.contains(&"only-this".to_string()),
1039            "additive allow items from project overlay should still be applied"
1040        );
1041        assert!(
1042            config.commands.ask.contains(&"only-ask".to_string()),
1043            "additive ask items from project overlay should still be applied"
1044        );
1045        assert!(
1046            config.commands.deny.contains(&"only-deny".to_string()),
1047            "additive deny items from project overlay should still be applied"
1048        );
1049
1050        // Sections NOT mentioned in the overlay are untouched.
1051        assert!(
1052            !config.git.read_only.is_empty(),
1053            "git read_only should be unaffected by a commands-only overlay"
1054        );
1055
1056        std::fs::remove_dir_all(&root).ok();
1057    }
1058
1059    #[test]
1060    fn config_load_project_overlay_remove_deny_is_stripped() {
1061        require_nextest();
1062
1063        let root = scratch_dir("load-remove-deny-stripped");
1064        std::fs::create_dir(root.join(".git")).unwrap();
1065        std::fs::create_dir(root.join(".claude")).unwrap();
1066        // A project overlay that tries to remove items from deny — must be stripped.
1067        std::fs::write(
1068            root.join(".claude/cc-toolgate.toml"),
1069            r#"
1070            [commands]
1071            remove_deny = ["shred"]
1072            "#,
1073        )
1074        .unwrap();
1075
1076        let fake_home = root.join("fakehome");
1077        std::fs::create_dir_all(&fake_home).unwrap();
1078        unsafe { std::env::set_var("HOME", &fake_home) };
1079
1080        let original_dir = std::env::current_dir().unwrap();
1081        std::env::set_current_dir(&root).unwrap();
1082
1083        let config = Config::load();
1084
1085        std::env::set_current_dir(&original_dir).unwrap();
1086
1087        // remove_deny is stripped: "shred" must still be in deny list.
1088        assert!(
1089            config.commands.deny.contains(&"shred".to_string()),
1090            "remove_deny must be stripped; 'shred' should remain in deny list"
1091        );
1092
1093        std::fs::remove_dir_all(&root).ok();
1094    }
1095
1096    #[test]
1097    fn strip_project_overlay_dangerous_fields_clears_all_sections() {
1098        let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1099        let mut overlay = ConfigOverlay {
1100            commands: CommandsOverlay {
1101                replace: true,
1102                remove_allow: vec!["cat".into()],
1103                remove_ask: vec!["rm".into()],
1104                remove_deny: vec!["shred".into()],
1105                allow: vec!["my-tool".into()],
1106                ..Default::default()
1107            },
1108            wrappers: WrappersOverlay {
1109                replace: true,
1110                remove_allow_floor: vec!["xargs".into()],
1111                remove_ask_floor: vec!["sudo".into()],
1112                ..Default::default()
1113            },
1114            git: GitOverlay {
1115                replace: true,
1116                remove_read_only: vec!["status".into()],
1117                remove_allowed_with_config: vec!["push".into()],
1118                remove_force_push_flags: vec!["--force".into()],
1119                read_only: vec!["log".into()],
1120                ..Default::default()
1121            },
1122            cargo: CargoOverlay {
1123                replace: true,
1124                remove_safe_subcommands: vec!["build".into()],
1125                remove_allowed_with_config: vec!["publish".into()],
1126                ..Default::default()
1127            },
1128            kubectl: KubectlOverlay {
1129                replace: true,
1130                remove_read_only: vec!["get".into()],
1131                remove_mutating: vec!["apply".into()],
1132                remove_allowed_with_config: vec!["exec".into()],
1133                ..Default::default()
1134            },
1135            gh: GhOverlay {
1136                replace: true,
1137                remove_read_only: vec!["pr list".into()],
1138                remove_mutating: vec!["pr merge".into()],
1139                remove_allowed_with_config: vec!["pr create".into()],
1140                ..Default::default()
1141            },
1142            ..Default::default()
1143        };
1144
1145        strip_project_overlay_dangerous_fields(&mut overlay, &path);
1146
1147        // All dangerous fields cleared.
1148        assert!(!overlay.commands.replace);
1149        assert!(overlay.commands.remove_allow.is_empty());
1150        assert!(overlay.commands.remove_ask.is_empty());
1151        assert!(overlay.commands.remove_deny.is_empty());
1152
1153        assert!(!overlay.wrappers.replace);
1154        assert!(overlay.wrappers.remove_allow_floor.is_empty());
1155        assert!(overlay.wrappers.remove_ask_floor.is_empty());
1156
1157        assert!(!overlay.git.replace);
1158        assert!(overlay.git.remove_read_only.is_empty());
1159        assert!(overlay.git.remove_allowed_with_config.is_empty());
1160        assert!(overlay.git.remove_force_push_flags.is_empty());
1161
1162        assert!(!overlay.cargo.replace);
1163        assert!(overlay.cargo.remove_safe_subcommands.is_empty());
1164        assert!(overlay.cargo.remove_allowed_with_config.is_empty());
1165
1166        assert!(!overlay.kubectl.replace);
1167        assert!(overlay.kubectl.remove_read_only.is_empty());
1168        assert!(overlay.kubectl.remove_mutating.is_empty());
1169        assert!(overlay.kubectl.remove_allowed_with_config.is_empty());
1170
1171        assert!(!overlay.gh.replace);
1172        assert!(overlay.gh.remove_read_only.is_empty());
1173        assert!(overlay.gh.remove_mutating.is_empty());
1174        assert!(overlay.gh.remove_allowed_with_config.is_empty());
1175
1176        // Additive fields are preserved.
1177        assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1178        assert_eq!(overlay.git.read_only, vec!["log"]);
1179    }
1180
1181    #[test]
1182    fn strip_project_overlay_no_op_when_safe() {
1183        let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1184        let mut overlay = ConfigOverlay {
1185            commands: CommandsOverlay {
1186                allow: vec!["my-tool".into()],
1187                ..Default::default()
1188            },
1189            ..Default::default()
1190        };
1191
1192        // Should not panic, and should leave additive fields intact.
1193        strip_project_overlay_dangerous_fields(&mut overlay, &path);
1194        assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1195        assert!(!overlay.commands.replace);
1196    }
1197
1198    #[test]
1199    fn config_load_no_project_overlay_path_when_absent() {
1200        require_nextest();
1201
1202        let root = scratch_dir("load-no-overlay");
1203        // No .git, no .claude — Config::load() should not find a project overlay.
1204
1205        let fake_home = root.join("fakehome");
1206        std::fs::create_dir_all(&fake_home).unwrap();
1207        unsafe { std::env::set_var("HOME", &fake_home) };
1208
1209        let original_dir = std::env::current_dir().unwrap();
1210        std::env::set_current_dir(&root).unwrap();
1211
1212        let config = Config::load();
1213
1214        std::env::set_current_dir(&original_dir).unwrap();
1215
1216        assert!(
1217            config.project_overlay_path.is_none(),
1218            "project_overlay_path should be None when no overlay is found"
1219        );
1220
1221        std::fs::remove_dir_all(&root).ok();
1222    }
1223}