Skip to main content

cc_toolgate/commands/tools/
cargo.rs

1//! Subcommand-aware cargo evaluation.
2//!
3//! Distinguishes safe subcommands (build, test, clippy) from mutating ones
4//! (install, publish). Supports env-gated auto-allow and `--version`/`-V` detection.
5
6use super::super::CommandSpec;
7use crate::config::CargoConfig;
8use crate::eval::{CommandContext, Decision, RuleMatch};
9use agent_shell_parser::parse::Word;
10use std::collections::HashMap;
11
12/// Subcommand-aware cargo evaluator.
13///
14/// Evaluation order:
15/// 1. Safe subcommands → ALLOW (with redirection escalation)
16/// 2. Env-gated subcommands → ALLOW if all `config_env` entries match, else ASK
17/// 3. `--version` / `-V` → ALLOW
18/// 4. Everything else → ASK
19pub struct CargoSpec {
20    /// Subcommands that are always safe (e.g. `build`, `test`, `check`).
21    safe_subcommands: Vec<String>,
22    /// Subcommands allowed only when all `config_env` entries match.
23    allowed_with_config: Vec<String>,
24    /// Required env var name→value pairs that gate `allowed_with_config` subcommands.
25    config_env: HashMap<String, String>,
26}
27
28impl CargoSpec {
29    /// Build a cargo spec from configuration.
30    pub fn from_config(config: &CargoConfig) -> Self {
31        Self {
32            safe_subcommands: config.safe_subcommands.clone(),
33            allowed_with_config: config.allowed_with_config.clone(),
34            config_env: config.config_env.clone(),
35        }
36    }
37
38    /// Extract the cargo subcommand (first non-flag word after "cargo").
39    /// Handles env var prefixes like `CARGO_INSTALL_ROOT=/tmp cargo install`.
40    fn subcommand(ctx: &CommandContext) -> Option<&Word> {
41        let mut iter = ctx.words.iter();
42        for word in iter.by_ref() {
43            if word == "cargo" {
44                return iter.find(|w| !w.is_flag());
45            }
46        }
47        None
48    }
49
50    /// Format config_env keys for reason strings.
51    fn env_keys_display(&self) -> String {
52        let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
53        keys.sort();
54        keys.join(", ")
55    }
56}
57
58impl CommandSpec for CargoSpec {
59    fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
60        let sub_str: &str = Self::subcommand(ctx).map(|w| w.as_str()).unwrap_or("?");
61
62        if self.safe_subcommands.iter().any(|s| s == sub_str) {
63            if let Some(ref r) = ctx.redirection {
64                return RuleMatch {
65                    decision: Decision::Ask,
66                    reason: format!("cargo {sub_str} with {}", r),
67                };
68            }
69            return RuleMatch {
70                decision: Decision::Allow,
71                reason: format!("cargo {sub_str}"),
72            };
73        }
74
75        // Env-gated subcommands: allowed only when all config_env entries match
76        if self.allowed_with_config.iter().any(|s| s == sub_str) {
77            if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
78                if let Some(ref r) = ctx.redirection {
79                    return RuleMatch {
80                        decision: Decision::Ask,
81                        reason: format!("cargo {sub_str} with {}", r),
82                    };
83                }
84                return RuleMatch {
85                    decision: Decision::Allow,
86                    reason: format!("cargo {sub_str} with {}", self.env_keys_display()),
87                };
88            }
89            return RuleMatch {
90                decision: Decision::Ask,
91                reason: format!("cargo {sub_str} requires confirmation"),
92            };
93        }
94
95        // --version / -V at any position
96        if ctx.has_any_flag(&["--version", "-V"]) {
97            return RuleMatch {
98                decision: Decision::Allow,
99                reason: "cargo --version".into(),
100            };
101        }
102
103        RuleMatch {
104            decision: Decision::Ask,
105            reason: format!("cargo {sub_str} requires confirmation"),
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::config::Config;
114
115    fn spec() -> CargoSpec {
116        CargoSpec::from_config(&Config::default_config().cargo)
117    }
118
119    fn eval(cmd: &str) -> Decision {
120        let s = spec();
121        let ctx = CommandContext::from_command(cmd);
122        s.evaluate(&ctx).decision
123    }
124
125    #[test]
126    fn allow_build() {
127        assert_eq!(eval("cargo build --release"), Decision::Allow);
128    }
129
130    #[test]
131    fn allow_test() {
132        assert_eq!(eval("cargo test"), Decision::Allow);
133    }
134
135    #[test]
136    fn allow_clippy() {
137        assert_eq!(eval("cargo clippy"), Decision::Allow);
138    }
139
140    #[test]
141    fn allow_version() {
142        assert_eq!(eval("cargo --version"), Decision::Allow);
143    }
144
145    #[test]
146    fn allow_version_short() {
147        assert_eq!(eval("cargo -V"), Decision::Allow);
148    }
149
150    #[test]
151    fn ask_install() {
152        assert_eq!(eval("cargo install ripgrep"), Decision::Ask);
153    }
154
155    #[test]
156    fn ask_publish() {
157        assert_eq!(eval("cargo publish"), Decision::Ask);
158    }
159
160    #[test]
161    fn redir_build() {
162        assert_eq!(eval("cargo build --release > /tmp/log"), Decision::Ask);
163    }
164
165    // ── Env-gated commands ──
166
167    fn spec_with_env_gate() -> CargoSpec {
168        CargoSpec::from_config(&CargoConfig {
169            safe_subcommands: vec!["build".into(), "check".into(), "test".into()],
170            allowed_with_config: vec!["install".into(), "publish".into()],
171            config_env: HashMap::from([("CARGO_INSTALL_ROOT".into(), "/tmp/bin".into())]),
172        })
173    }
174
175    fn eval_with_env_gate(cmd: &str) -> Decision {
176        let s = spec_with_env_gate();
177        let ctx = CommandContext::from_command(cmd);
178        s.evaluate(&ctx).decision
179    }
180
181    #[test]
182    fn env_gate_install_with_matching_value() {
183        assert_eq!(
184            eval_with_env_gate("CARGO_INSTALL_ROOT=/tmp/bin cargo install ripgrep"),
185            Decision::Allow
186        );
187    }
188
189    #[test]
190    fn env_gate_install_with_wrong_value() {
191        assert_eq!(
192            eval_with_env_gate("CARGO_INSTALL_ROOT=/usr/local cargo install ripgrep"),
193            Decision::Ask
194        );
195    }
196
197    #[test]
198    fn env_gate_install_no_config() {
199        assert_eq!(eval_with_env_gate("cargo install ripgrep"), Decision::Ask);
200    }
201
202    #[test]
203    fn env_gate_publish_with_config() {
204        assert_eq!(
205            eval_with_env_gate("CARGO_INSTALL_ROOT=/tmp/bin cargo publish"),
206            Decision::Allow
207        );
208    }
209
210    #[test]
211    fn env_gate_build_still_safe_no_env() {
212        // safe_subcommands don't need the env var
213        assert_eq!(eval_with_env_gate("cargo build"), Decision::Allow);
214    }
215}