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
324/// Remove items in `winners` from `losers`.
325///
326/// Used after merging to enforce cross-list priority: if a command
327/// appears in a higher-priority list (e.g. allow), it must be removed
328/// from lower-priority lists (e.g. ask, deny). This prevents stale
329/// entries from default config from shadowing user overrides when the
330/// registry is built with last-writer-wins insertion order.
331fn dedup_winners_over_losers(winners: &[String], losers: &mut Vec<String>) {
332    losers.retain(|item| !winners.contains(item));
333}
334
335impl Config {
336    /// Load the default embedded configuration.
337    pub fn default_config() -> Self {
338        toml::from_str(DEFAULT_CONFIG).expect("embedded default config must parse")
339    }
340
341    /// Load configuration with resolution order:
342    /// 1. Start with embedded defaults
343    /// 2. Merge user overlay from ~/.config/cc-toolgate/config.toml (if exists)
344    /// 3. Merge project overlay from <git-root>/.claude/cc-toolgate.toml
345    ///    (if CWD is inside a git repo and the file exists)
346    ///
347    /// Each overlay merges with what's below it: lists extend, scalars override.
348    /// Set `replace = true` in any section to replace its defaults entirely.
349    /// Use `remove_<field>` lists to subtract specific items.
350    ///
351    /// When a project overlay is loaded, `project_overlay_path` is set to the
352    /// file that was applied. Callers can inspect this to annotate decisions
353    /// with provenance information.
354    pub fn load() -> Self {
355        let mut config = Self::default_config();
356        if let Some(overlay) = Self::load_overlay() {
357            config.apply_overlay(overlay);
358        }
359        if let Some((overlay, path)) = Self::load_project_overlay() {
360            config.apply_overlay(overlay);
361            config.project_overlay_path = Some(path);
362        }
363        config
364    }
365
366    /// Try to load user overlay from ~/.config/cc-toolgate/config.toml.
367    fn load_overlay() -> Option<ConfigOverlay> {
368        let home = std::env::var_os("HOME")?;
369        let path = std::path::Path::new(&home).join(".config/cc-toolgate/config.toml");
370        load_overlay_from_path(&path, "config parse error")
371    }
372
373    /// Try to load project overlay from <git-root>/.claude/cc-toolgate.toml.
374    /// Returns both the parsed overlay and the path it was loaded from.
375    ///
376    /// Project overlays may only ADD to allow/ask/deny lists. Any `replace` flags
377    /// or `remove_*` lists are stripped and a warning is emitted. This prevents a
378    /// malicious project config from removing safety rules set at the user level.
379    fn load_project_overlay() -> Option<(ConfigOverlay, std::path::PathBuf)> {
380        let cwd = std::env::current_dir().ok()?;
381        let git_root = find_git_root(&cwd)?;
382        let path = git_root.join(".claude/cc-toolgate.toml");
383        let mut overlay = load_overlay_from_path(&path, "project config parse error")?;
384        strip_project_overlay_dangerous_fields(&mut overlay, &path);
385        Some((overlay, path))
386    }
387
388    /// Apply an overlay on top of this config (merge semantics).
389    fn apply_overlay(&mut self, overlay: ConfigOverlay) {
390        // Settings: scalar overrides
391        if let Some(v) = overlay.settings.escalate_deny {
392            self.settings.escalate_deny = v;
393        }
394
395        // Commands
396        let c = overlay.commands;
397        merge_list(
398            &mut self.commands.allow,
399            c.allow,
400            &c.remove_allow,
401            c.replace,
402        );
403        merge_list(&mut self.commands.ask, c.ask, &c.remove_ask, c.replace);
404        merge_list(&mut self.commands.deny, c.deny, &c.remove_deny, c.replace);
405
406        // Cross-list dedup: allow > ask > deny.
407        // If a command was added to allow (e.g. user config promotes curl from
408        // ask to allow), remove it from ask and deny so it doesn't get clobbered
409        // by last-writer-wins in the registry.
410        dedup_winners_over_losers(&self.commands.allow, &mut self.commands.ask);
411        dedup_winners_over_losers(&self.commands.allow, &mut self.commands.deny);
412        dedup_winners_over_losers(&self.commands.ask, &mut self.commands.deny);
413
414        // Wrappers
415        let w = overlay.wrappers;
416        merge_list(
417            &mut self.wrappers.allow_floor,
418            w.allow_floor,
419            &w.remove_allow_floor,
420            w.replace,
421        );
422        merge_list(
423            &mut self.wrappers.ask_floor,
424            w.ask_floor,
425            &w.remove_ask_floor,
426            w.replace,
427        );
428
429        // Cross-list dedup for wrappers: allow_floor > ask_floor.
430        dedup_winners_over_losers(&self.wrappers.allow_floor, &mut self.wrappers.ask_floor);
431
432        // Git
433        let g = overlay.git;
434        merge_list(
435            &mut self.git.read_only,
436            g.read_only,
437            &g.remove_read_only,
438            g.replace,
439        );
440        merge_list(
441            &mut self.git.allowed_with_config,
442            g.allowed_with_config,
443            &g.remove_allowed_with_config,
444            g.replace,
445        );
446        merge_list(
447            &mut self.git.force_push_flags,
448            g.force_push_flags,
449            &g.remove_force_push_flags,
450            g.replace,
451        );
452        if let Some(v) = g.config_env {
453            self.git.config_env = v;
454        }
455
456        // Cargo
457        let ca = overlay.cargo;
458        merge_list(
459            &mut self.cargo.safe_subcommands,
460            ca.safe_subcommands,
461            &ca.remove_safe_subcommands,
462            ca.replace,
463        );
464        merge_list(
465            &mut self.cargo.allowed_with_config,
466            ca.allowed_with_config,
467            &ca.remove_allowed_with_config,
468            ca.replace,
469        );
470        if let Some(v) = ca.config_env {
471            self.cargo.config_env = v;
472        }
473
474        // Kubectl
475        let k = overlay.kubectl;
476        merge_list(
477            &mut self.kubectl.read_only,
478            k.read_only,
479            &k.remove_read_only,
480            k.replace,
481        );
482        merge_list(
483            &mut self.kubectl.mutating,
484            k.mutating,
485            &k.remove_mutating,
486            k.replace,
487        );
488        merge_list(
489            &mut self.kubectl.allowed_with_config,
490            k.allowed_with_config,
491            &k.remove_allowed_with_config,
492            k.replace,
493        );
494        if let Some(v) = k.config_env {
495            self.kubectl.config_env = v;
496        }
497
498        // Gh
499        let gh = overlay.gh;
500        merge_list(
501            &mut self.gh.read_only,
502            gh.read_only,
503            &gh.remove_read_only,
504            gh.replace,
505        );
506        merge_list(
507            &mut self.gh.mutating,
508            gh.mutating,
509            &gh.remove_mutating,
510            gh.replace,
511        );
512        merge_list(
513            &mut self.gh.allowed_with_config,
514            gh.allowed_with_config,
515            &gh.remove_allowed_with_config,
516            gh.replace,
517        );
518        if let Some(v) = gh.config_env {
519            self.gh.config_env = v;
520        }
521    }
522
523    /// Apply an overlay from a TOML string. Used for testing.
524    #[cfg(test)]
525    fn apply_overlay_str(&mut self, toml_str: &str) {
526        let overlay: ConfigOverlay = toml::from_str(toml_str).unwrap();
527        self.apply_overlay(overlay);
528    }
529}
530
531/// Strip `replace` flags and `remove_*` lists from a project overlay.
532///
533/// Project overlays are untrusted: they live in the repo and could be crafted
534/// by a malicious project to remove safety rules the user relies on. This
535/// function enforces the invariant that project overlays can only ADD entries,
536/// never remove or replace them. If any dangerous field was non-empty/true, a
537/// warning is printed to stderr.
538fn strip_project_overlay_dangerous_fields(overlay: &mut ConfigOverlay, path: &std::path::Path) {
539    let mut stripped = false;
540
541    // commands
542    if overlay.commands.replace
543        || !overlay.commands.remove_allow.is_empty()
544        || !overlay.commands.remove_ask.is_empty()
545        || !overlay.commands.remove_deny.is_empty()
546    {
547        stripped = true;
548    }
549    overlay.commands.replace = false;
550    overlay.commands.remove_allow.clear();
551    overlay.commands.remove_ask.clear();
552    overlay.commands.remove_deny.clear();
553
554    // wrappers
555    if overlay.wrappers.replace
556        || !overlay.wrappers.remove_allow_floor.is_empty()
557        || !overlay.wrappers.remove_ask_floor.is_empty()
558    {
559        stripped = true;
560    }
561    overlay.wrappers.replace = false;
562    overlay.wrappers.remove_allow_floor.clear();
563    overlay.wrappers.remove_ask_floor.clear();
564
565    // git
566    if overlay.git.replace
567        || !overlay.git.remove_read_only.is_empty()
568        || !overlay.git.remove_allowed_with_config.is_empty()
569        || !overlay.git.remove_force_push_flags.is_empty()
570    {
571        stripped = true;
572    }
573    overlay.git.replace = false;
574    overlay.git.remove_read_only.clear();
575    overlay.git.remove_allowed_with_config.clear();
576    overlay.git.remove_force_push_flags.clear();
577
578    // cargo
579    if overlay.cargo.replace
580        || !overlay.cargo.remove_safe_subcommands.is_empty()
581        || !overlay.cargo.remove_allowed_with_config.is_empty()
582    {
583        stripped = true;
584    }
585    overlay.cargo.replace = false;
586    overlay.cargo.remove_safe_subcommands.clear();
587    overlay.cargo.remove_allowed_with_config.clear();
588
589    // kubectl
590    if overlay.kubectl.replace
591        || !overlay.kubectl.remove_read_only.is_empty()
592        || !overlay.kubectl.remove_mutating.is_empty()
593        || !overlay.kubectl.remove_allowed_with_config.is_empty()
594    {
595        stripped = true;
596    }
597    overlay.kubectl.replace = false;
598    overlay.kubectl.remove_read_only.clear();
599    overlay.kubectl.remove_mutating.clear();
600    overlay.kubectl.remove_allowed_with_config.clear();
601
602    // gh
603    if overlay.gh.replace
604        || !overlay.gh.remove_read_only.is_empty()
605        || !overlay.gh.remove_mutating.is_empty()
606        || !overlay.gh.remove_allowed_with_config.is_empty()
607    {
608        stripped = true;
609    }
610    overlay.gh.replace = false;
611    overlay.gh.remove_read_only.clear();
612    overlay.gh.remove_mutating.clear();
613    overlay.gh.remove_allowed_with_config.clear();
614
615    if stripped {
616        eprintln!(
617            "cc-toolgate: project overlay at {} attempted to use replace/remove — stripped for security",
618            path.display()
619        );
620    }
621}
622
623/// Read and parse a ConfigOverlay from `path`. Returns `None` if the file
624/// can't be read (missing, permission denied, etc.); logs to stderr and
625/// returns `None` on parse errors.
626fn load_overlay_from_path(path: &std::path::Path, err_label: &str) -> Option<ConfigOverlay> {
627    let content = std::fs::read_to_string(path).ok()?;
628    match toml::from_str(&content) {
629        Ok(overlay) => Some(overlay),
630        Err(e) => {
631            eprintln!("cc-toolgate: {err_label}: {e}");
632            None
633        }
634    }
635}
636
637/// Walk up from `start` looking for a `.git` entry (dir for normal repos,
638/// file for worktrees). Returns the containing directory, or `None` if no
639/// ancestor contains `.git`.
640fn find_git_root(start: &std::path::Path) -> Option<std::path::PathBuf> {
641    let mut current = Some(start);
642    while let Some(dir) = current {
643        if dir.join(".git").exists() {
644            return Some(dir.to_path_buf());
645        }
646        current = dir.parent();
647    }
648    None
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn default_config_parses() {
657        let config = Config::default_config();
658        assert!(!config.commands.allow.is_empty());
659        assert!(!config.commands.ask.is_empty());
660        assert!(!config.commands.deny.is_empty());
661        assert!(!config.git.read_only.is_empty());
662        assert!(!config.cargo.safe_subcommands.is_empty());
663        assert!(!config.kubectl.read_only.is_empty());
664        assert!(!config.gh.read_only.is_empty());
665    }
666
667    #[test]
668    fn default_config_has_expected_commands() {
669        let config = Config::default_config();
670        assert!(config.commands.allow.contains(&"ls".to_string()));
671        assert!(config.commands.ask.contains(&"rm".to_string()));
672        assert!(config.commands.deny.contains(&"shred".to_string()));
673    }
674
675    #[test]
676    fn default_escalate_deny_is_false() {
677        let config = Config::default_config();
678        assert!(!config.settings.escalate_deny);
679    }
680
681    #[test]
682    fn default_git_env_gate_disabled() {
683        let config = Config::default_config();
684        assert!(config.git.config_env.is_empty());
685        assert!(config.git.allowed_with_config.is_empty());
686    }
687
688    // ── Merge semantics ──
689
690    #[test]
691    fn overlay_extends_allow_list() {
692        let mut config = Config::default_config();
693        config.apply_overlay_str(
694            r#"
695            [commands]
696            allow = ["my-tool"]
697        "#,
698        );
699        // Default allow list still present
700        assert!(config.commands.allow.contains(&"ls".to_string()));
701        // New item added
702        assert!(config.commands.allow.contains(&"my-tool".to_string()));
703    }
704
705    #[test]
706    fn overlay_removes_from_allow_list() {
707        let mut config = Config::default_config();
708        config.apply_overlay_str(
709            r#"
710            [commands]
711            remove_allow = ["cat", "find"]
712        "#,
713        );
714        assert!(!config.commands.allow.contains(&"cat".to_string()));
715        assert!(!config.commands.allow.contains(&"find".to_string()));
716        // Other items still present
717        assert!(config.commands.allow.contains(&"ls".to_string()));
718    }
719
720    #[test]
721    fn default_wrappers_populated() {
722        let config = Config::default_config();
723        assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
724        assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
725        assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
726        assert!(config.wrappers.ask_floor.contains(&"doas".to_string()));
727        // These should NOT be in commands.allow/ask anymore
728        assert!(!config.commands.allow.contains(&"xargs".to_string()));
729        assert!(!config.commands.allow.contains(&"env".to_string()));
730        assert!(!config.commands.ask.contains(&"sudo".to_string()));
731    }
732
733    #[test]
734    fn overlay_removes_from_wrappers() {
735        let mut config = Config::default_config();
736        config.apply_overlay_str(
737            r#"
738            [wrappers]
739            remove_allow_floor = ["xargs"]
740        "#,
741        );
742        assert!(!config.wrappers.allow_floor.contains(&"xargs".to_string()));
743        // Others untouched
744        assert!(config.wrappers.allow_floor.contains(&"env".to_string()));
745    }
746
747    #[test]
748    fn overlay_extends_wrappers() {
749        let mut config = Config::default_config();
750        config.apply_overlay_str(
751            r#"
752            [wrappers]
753            allow_floor = ["my-wrapper"]
754        "#,
755        );
756        assert!(
757            config
758                .wrappers
759                .allow_floor
760                .contains(&"my-wrapper".to_string())
761        );
762        assert!(config.wrappers.allow_floor.contains(&"xargs".to_string()));
763    }
764
765    #[test]
766    fn overlay_replace_commands() {
767        let mut config = Config::default_config();
768        config.apply_overlay_str(
769            r#"
770            [commands]
771            replace = true
772            allow = ["ls", "cat"]
773            ask = ["rm"]
774            deny = ["shred"]
775        "#,
776        );
777        assert_eq!(config.commands.allow, vec!["ls", "cat"]);
778        assert_eq!(config.commands.ask, vec!["rm"]);
779        assert_eq!(config.commands.deny, vec!["shred"]);
780    }
781
782    #[test]
783    fn overlay_git_env_gate() {
784        let mut config = Config::default_config();
785        config.apply_overlay_str(
786            r#"
787            [git]
788            allowed_with_config = ["commit", "add", "push"]
789            [git.config_env]
790            GIT_CONFIG_GLOBAL = "~/.gitconfig.ai"
791        "#,
792        );
793        assert_eq!(
794            config.git.config_env.get("GIT_CONFIG_GLOBAL").unwrap(),
795            "~/.gitconfig.ai"
796        );
797        assert_eq!(
798            config.git.allowed_with_config,
799            vec!["commit", "add", "push"]
800        );
801        // Default read_only still present
802        assert!(config.git.read_only.contains(&"status".to_string()));
803        assert!(config.git.read_only.contains(&"log".to_string()));
804    }
805
806    #[test]
807    fn overlay_escalate_deny() {
808        let mut config = Config::default_config();
809        config.apply_overlay_str(
810            r#"
811            [settings]
812            escalate_deny = true
813        "#,
814        );
815        assert!(config.settings.escalate_deny);
816    }
817
818    #[test]
819    fn overlay_omitted_settings_unchanged() {
820        let mut config = Config::default_config();
821        config.apply_overlay_str(
822            r#"
823            [commands]
824            allow = ["my-tool"]
825        "#,
826        );
827        // Settings not in overlay remain at defaults
828        assert!(!config.settings.escalate_deny);
829    }
830
831    #[test]
832    fn overlay_no_duplicates() {
833        let mut config = Config::default_config();
834        config.apply_overlay_str(
835            r#"
836            [commands]
837            allow = ["ls"]
838        "#,
839        );
840        let count = config.commands.allow.iter().filter(|s| *s == "ls").count();
841        assert_eq!(count, 1);
842    }
843
844    #[test]
845    fn overlay_remove_and_add() {
846        let mut config = Config::default_config();
847        // Move "eval" from deny to ask
848        config.apply_overlay_str(
849            r#"
850            [commands]
851            remove_deny = ["eval"]
852            ask = ["eval"]
853        "#,
854        );
855        assert!(!config.commands.deny.contains(&"eval".to_string()));
856        assert!(config.commands.ask.contains(&"eval".to_string()));
857    }
858
859    #[test]
860    fn overlay_promote_ask_to_allow_deduplicates() {
861        // Reproducer for the config-merge-dedup bug: curl is in the default
862        // ask list. When a user overlay adds curl to allow, it should be
863        // removed from ask. Without cross-list dedup, curl ends up in BOTH
864        // lists, and the registry's last-writer-wins makes it ASK again.
865        let mut config = Config::default_config();
866        assert!(
867            config.commands.ask.contains(&"curl".to_string()),
868            "curl should be in default ask list"
869        );
870
871        config.apply_overlay_str(
872            r#"
873            [commands]
874            allow = ["curl"]
875        "#,
876        );
877
878        assert!(
879            config.commands.allow.contains(&"curl".to_string()),
880            "curl should be in allow after overlay"
881        );
882        assert!(
883            !config.commands.ask.contains(&"curl".to_string()),
884            "curl must be removed from ask when promoted to allow"
885        );
886    }
887
888    #[test]
889    fn overlay_promote_deny_to_ask_deduplicates() {
890        // When a user promotes a command from deny to ask, it should be
891        // removed from deny without needing explicit remove_deny.
892        let mut config = Config::default_config();
893        assert!(config.commands.deny.contains(&"eval".to_string()));
894
895        config.apply_overlay_str(
896            r#"
897            [commands]
898            ask = ["eval"]
899        "#,
900        );
901
902        assert!(
903            config.commands.ask.contains(&"eval".to_string()),
904            "eval should be in ask after overlay"
905        );
906        assert!(
907            !config.commands.deny.contains(&"eval".to_string()),
908            "eval must be removed from deny when promoted to ask"
909        );
910    }
911
912    #[test]
913    fn overlay_promote_deny_to_allow_deduplicates() {
914        // Promoting from deny all the way to allow: must clear from both
915        // deny and ask.
916        let mut config = Config::default_config();
917        assert!(config.commands.deny.contains(&"eval".to_string()));
918
919        config.apply_overlay_str(
920            r#"
921            [commands]
922            allow = ["eval"]
923        "#,
924        );
925
926        assert!(
927            config.commands.allow.contains(&"eval".to_string()),
928            "eval should be in allow after overlay"
929        );
930        assert!(
931            !config.commands.deny.contains(&"eval".to_string()),
932            "eval must be removed from deny when promoted to allow"
933        );
934        assert!(
935            !config.commands.ask.contains(&"eval".to_string()),
936            "eval must not appear in ask when promoted to allow"
937        );
938    }
939
940    #[test]
941    fn wrapper_promote_ask_floor_to_allow_floor_deduplicates() {
942        // If a user moves a wrapper from ask_floor to allow_floor,
943        // cross-list dedup should remove it from ask_floor.
944        let mut config = Config::default_config();
945        assert!(config.wrappers.ask_floor.contains(&"sudo".to_string()));
946
947        config.apply_overlay_str(
948            r#"
949            [wrappers]
950            allow_floor = ["sudo"]
951        "#,
952        );
953
954        assert!(
955            config.wrappers.allow_floor.contains(&"sudo".to_string()),
956            "sudo should be in allow_floor after overlay"
957        );
958        assert!(
959            !config.wrappers.ask_floor.contains(&"sudo".to_string()),
960            "sudo must be removed from ask_floor when promoted to allow_floor"
961        );
962    }
963
964    #[test]
965    fn overlay_replace_git() {
966        let mut config = Config::default_config();
967        config.apply_overlay_str(
968            r#"
969            [git]
970            replace = true
971            read_only = ["status", "log"]
972            force_push_flags = ["--force"]
973        "#,
974        );
975        assert_eq!(config.git.read_only, vec!["status", "log"]);
976        assert_eq!(config.git.force_push_flags, vec!["--force"]);
977        assert!(config.git.allowed_with_config.is_empty());
978    }
979
980    #[test]
981    fn overlay_unrelated_sections_untouched() {
982        let mut config = Config::default_config();
983        let original_kubectl_read_only = config.kubectl.read_only.clone();
984        config.apply_overlay_str(
985            r#"
986            [git]
987            allowed_with_config = ["push"]
988            config_env_var = "GIT_CONFIG_GLOBAL"
989        "#,
990        );
991        assert_eq!(config.kubectl.read_only, original_kubectl_read_only);
992    }
993
994    #[test]
995    fn empty_overlay_changes_nothing() {
996        let original = Config::default_config();
997        let mut config = Config::default_config();
998        config.apply_overlay_str("");
999        assert_eq!(config.commands.allow.len(), original.commands.allow.len());
1000        assert_eq!(config.git.read_only.len(), original.git.read_only.len());
1001    }
1002
1003    // ── Project overlay discovery ──
1004
1005    /// Make a scratch dir under std::env::temp_dir() unique to this test run.
1006    fn scratch_dir(tag: &str) -> std::path::PathBuf {
1007        let nanos = std::time::SystemTime::now()
1008            .duration_since(std::time::UNIX_EPOCH)
1009            .unwrap()
1010            .as_nanos();
1011        let dir = std::env::temp_dir().join(format!("cc-toolgate-test-{tag}-{nanos}"));
1012        std::fs::create_dir_all(&dir).unwrap();
1013        dir
1014    }
1015
1016    #[test]
1017    fn find_git_root_finds_dot_git_in_ancestor() {
1018        let root = scratch_dir("find-root");
1019        std::fs::create_dir(root.join(".git")).unwrap();
1020        let deep = root.join("a/b/c");
1021        std::fs::create_dir_all(&deep).unwrap();
1022
1023        assert_eq!(find_git_root(&deep), Some(root.clone()));
1024        assert_eq!(find_git_root(&root), Some(root.clone()));
1025
1026        std::fs::remove_dir_all(&root).ok();
1027    }
1028
1029    #[test]
1030    fn project_overlay_file_parses_and_extends_allow() {
1031        let root = scratch_dir("project-overlay");
1032        std::fs::create_dir(root.join(".git")).unwrap();
1033        std::fs::create_dir(root.join(".claude")).unwrap();
1034        std::fs::write(
1035            root.join(".claude/cc-toolgate.toml"),
1036            r#"
1037            [commands]
1038            allow = ["my-project-tool"]
1039            "#,
1040        )
1041        .unwrap();
1042
1043        let path = root.join(".claude/cc-toolgate.toml");
1044        let overlay = load_overlay_from_path(&path, "test").expect("parses");
1045
1046        let mut config = Config::default_config();
1047        config.apply_overlay(overlay);
1048        assert!(
1049            config
1050                .commands
1051                .allow
1052                .contains(&"my-project-tool".to_string())
1053        );
1054
1055        std::fs::remove_dir_all(&root).ok();
1056    }
1057
1058    // ── Config::load() integration tests ──
1059    //
1060    // These tests change the process CWD and HOME, so they require nextest
1061    // (which runs each test in its own process) for isolation safety.
1062
1063    /// Assert we are running under nextest (process-per-test isolation).
1064    fn require_nextest() {
1065        assert!(
1066            std::env::var("NEXTEST").is_ok(),
1067            "this test mutates process CWD/HOME and requires nextest (cargo nextest run)"
1068        );
1069    }
1070
1071    #[test]
1072    fn config_load_applies_project_overlay() {
1073        require_nextest();
1074
1075        let root = scratch_dir("load-project");
1076        std::fs::create_dir(root.join(".git")).unwrap();
1077        std::fs::create_dir(root.join(".claude")).unwrap();
1078        std::fs::write(
1079            root.join(".claude/cc-toolgate.toml"),
1080            r#"
1081            [commands]
1082            allow = ["my-test-script"]
1083            "#,
1084        )
1085        .unwrap();
1086
1087        // Point HOME to a nonexistent dir so no user overlay interferes.
1088        let fake_home = root.join("fakehome");
1089        std::fs::create_dir_all(&fake_home).unwrap();
1090        unsafe { std::env::set_var("HOME", &fake_home) };
1091
1092        // Change CWD so Config::load() discovers the project overlay.
1093        let original_dir = std::env::current_dir().unwrap();
1094        std::env::set_current_dir(&root).unwrap();
1095
1096        let config = Config::load();
1097
1098        // Restore CWD before any assertions (in case they panic).
1099        std::env::set_current_dir(&original_dir).unwrap();
1100
1101        // Default commands are still present.
1102        assert!(
1103            config.commands.allow.contains(&"ls".to_string()),
1104            "default 'ls' should still be in allow list"
1105        );
1106        // Project overlay's addition is present.
1107        assert!(
1108            config
1109                .commands
1110                .allow
1111                .contains(&"my-test-script".to_string()),
1112            "project overlay should have added 'my-test-script' to allow list"
1113        );
1114        // project_overlay_path should be set.
1115        let expected_path = root.join(".claude/cc-toolgate.toml");
1116        assert_eq!(
1117            config.project_overlay_path,
1118            Some(expected_path),
1119            "project_overlay_path should record the overlay file"
1120        );
1121
1122        std::fs::remove_dir_all(&root).ok();
1123    }
1124
1125    #[test]
1126    fn config_load_project_overlay_replace_is_stripped() {
1127        require_nextest();
1128
1129        let root = scratch_dir("load-replace-stripped");
1130        std::fs::create_dir(root.join(".git")).unwrap();
1131        std::fs::create_dir(root.join(".claude")).unwrap();
1132        // A project overlay with replace = true — the replace flag must be
1133        // stripped for security. The additive items in the overlay are still
1134        // applied, but the default allow list is NOT discarded.
1135        std::fs::write(
1136            root.join(".claude/cc-toolgate.toml"),
1137            r#"
1138            [commands]
1139            replace = true
1140            allow = ["only-this"]
1141            ask = ["only-ask"]
1142            deny = ["only-deny"]
1143            "#,
1144        )
1145        .unwrap();
1146
1147        let fake_home = root.join("fakehome");
1148        std::fs::create_dir_all(&fake_home).unwrap();
1149        unsafe { std::env::set_var("HOME", &fake_home) };
1150
1151        let original_dir = std::env::current_dir().unwrap();
1152        std::env::set_current_dir(&root).unwrap();
1153
1154        let config = Config::load();
1155
1156        std::env::set_current_dir(&original_dir).unwrap();
1157
1158        // replace = true is stripped: the default allow list is preserved.
1159        assert!(
1160            config.commands.allow.contains(&"ls".to_string()),
1161            "replace should be stripped; default 'ls' must still be in allow list"
1162        );
1163        // The additive items from the overlay are still applied.
1164        assert!(
1165            config.commands.allow.contains(&"only-this".to_string()),
1166            "additive allow items from project overlay should still be applied"
1167        );
1168        assert!(
1169            config.commands.ask.contains(&"only-ask".to_string()),
1170            "additive ask items from project overlay should still be applied"
1171        );
1172        assert!(
1173            config.commands.deny.contains(&"only-deny".to_string()),
1174            "additive deny items from project overlay should still be applied"
1175        );
1176
1177        // Sections NOT mentioned in the overlay are untouched.
1178        assert!(
1179            !config.git.read_only.is_empty(),
1180            "git read_only should be unaffected by a commands-only overlay"
1181        );
1182
1183        std::fs::remove_dir_all(&root).ok();
1184    }
1185
1186    #[test]
1187    fn config_load_project_overlay_remove_deny_is_stripped() {
1188        require_nextest();
1189
1190        let root = scratch_dir("load-remove-deny-stripped");
1191        std::fs::create_dir(root.join(".git")).unwrap();
1192        std::fs::create_dir(root.join(".claude")).unwrap();
1193        // A project overlay that tries to remove items from deny — must be stripped.
1194        std::fs::write(
1195            root.join(".claude/cc-toolgate.toml"),
1196            r#"
1197            [commands]
1198            remove_deny = ["shred"]
1199            "#,
1200        )
1201        .unwrap();
1202
1203        let fake_home = root.join("fakehome");
1204        std::fs::create_dir_all(&fake_home).unwrap();
1205        unsafe { std::env::set_var("HOME", &fake_home) };
1206
1207        let original_dir = std::env::current_dir().unwrap();
1208        std::env::set_current_dir(&root).unwrap();
1209
1210        let config = Config::load();
1211
1212        std::env::set_current_dir(&original_dir).unwrap();
1213
1214        // remove_deny is stripped: "shred" must still be in deny list.
1215        assert!(
1216            config.commands.deny.contains(&"shred".to_string()),
1217            "remove_deny must be stripped; 'shred' should remain in deny list"
1218        );
1219
1220        std::fs::remove_dir_all(&root).ok();
1221    }
1222
1223    #[test]
1224    fn strip_project_overlay_dangerous_fields_clears_all_sections() {
1225        let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1226        let mut overlay = ConfigOverlay {
1227            commands: CommandsOverlay {
1228                replace: true,
1229                remove_allow: vec!["cat".into()],
1230                remove_ask: vec!["rm".into()],
1231                remove_deny: vec!["shred".into()],
1232                allow: vec!["my-tool".into()],
1233                ..Default::default()
1234            },
1235            wrappers: WrappersOverlay {
1236                replace: true,
1237                remove_allow_floor: vec!["xargs".into()],
1238                remove_ask_floor: vec!["sudo".into()],
1239                ..Default::default()
1240            },
1241            git: GitOverlay {
1242                replace: true,
1243                remove_read_only: vec!["status".into()],
1244                remove_allowed_with_config: vec!["push".into()],
1245                remove_force_push_flags: vec!["--force".into()],
1246                read_only: vec!["log".into()],
1247                ..Default::default()
1248            },
1249            cargo: CargoOverlay {
1250                replace: true,
1251                remove_safe_subcommands: vec!["build".into()],
1252                remove_allowed_with_config: vec!["publish".into()],
1253                ..Default::default()
1254            },
1255            kubectl: KubectlOverlay {
1256                replace: true,
1257                remove_read_only: vec!["get".into()],
1258                remove_mutating: vec!["apply".into()],
1259                remove_allowed_with_config: vec!["exec".into()],
1260                ..Default::default()
1261            },
1262            gh: GhOverlay {
1263                replace: true,
1264                remove_read_only: vec!["pr list".into()],
1265                remove_mutating: vec!["pr merge".into()],
1266                remove_allowed_with_config: vec!["pr create".into()],
1267                ..Default::default()
1268            },
1269            ..Default::default()
1270        };
1271
1272        strip_project_overlay_dangerous_fields(&mut overlay, &path);
1273
1274        // All dangerous fields cleared.
1275        assert!(!overlay.commands.replace);
1276        assert!(overlay.commands.remove_allow.is_empty());
1277        assert!(overlay.commands.remove_ask.is_empty());
1278        assert!(overlay.commands.remove_deny.is_empty());
1279
1280        assert!(!overlay.wrappers.replace);
1281        assert!(overlay.wrappers.remove_allow_floor.is_empty());
1282        assert!(overlay.wrappers.remove_ask_floor.is_empty());
1283
1284        assert!(!overlay.git.replace);
1285        assert!(overlay.git.remove_read_only.is_empty());
1286        assert!(overlay.git.remove_allowed_with_config.is_empty());
1287        assert!(overlay.git.remove_force_push_flags.is_empty());
1288
1289        assert!(!overlay.cargo.replace);
1290        assert!(overlay.cargo.remove_safe_subcommands.is_empty());
1291        assert!(overlay.cargo.remove_allowed_with_config.is_empty());
1292
1293        assert!(!overlay.kubectl.replace);
1294        assert!(overlay.kubectl.remove_read_only.is_empty());
1295        assert!(overlay.kubectl.remove_mutating.is_empty());
1296        assert!(overlay.kubectl.remove_allowed_with_config.is_empty());
1297
1298        assert!(!overlay.gh.replace);
1299        assert!(overlay.gh.remove_read_only.is_empty());
1300        assert!(overlay.gh.remove_mutating.is_empty());
1301        assert!(overlay.gh.remove_allowed_with_config.is_empty());
1302
1303        // Additive fields are preserved.
1304        assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1305        assert_eq!(overlay.git.read_only, vec!["log"]);
1306    }
1307
1308    #[test]
1309    fn strip_project_overlay_no_op_when_safe() {
1310        let path = std::path::PathBuf::from("/fake/path/.claude/cc-toolgate.toml");
1311        let mut overlay = ConfigOverlay {
1312            commands: CommandsOverlay {
1313                allow: vec!["my-tool".into()],
1314                ..Default::default()
1315            },
1316            ..Default::default()
1317        };
1318
1319        // Should not panic, and should leave additive fields intact.
1320        strip_project_overlay_dangerous_fields(&mut overlay, &path);
1321        assert_eq!(overlay.commands.allow, vec!["my-tool"]);
1322        assert!(!overlay.commands.replace);
1323    }
1324
1325    #[test]
1326    fn config_load_no_project_overlay_path_when_absent() {
1327        require_nextest();
1328
1329        let root = scratch_dir("load-no-overlay");
1330        // No .git, no .claude — Config::load() should not find a project overlay.
1331
1332        let fake_home = root.join("fakehome");
1333        std::fs::create_dir_all(&fake_home).unwrap();
1334        unsafe { std::env::set_var("HOME", &fake_home) };
1335
1336        let original_dir = std::env::current_dir().unwrap();
1337        std::env::set_current_dir(&root).unwrap();
1338
1339        let config = Config::load();
1340
1341        std::env::set_current_dir(&original_dir).unwrap();
1342
1343        assert!(
1344            config.project_overlay_path.is_none(),
1345            "project_overlay_path should be None when no overlay is found"
1346        );
1347
1348        std::fs::remove_dir_all(&root).ok();
1349    }
1350}