Skip to main content

cc_toolgate/commands/tools/
kubectl.rs

1//! Subcommand-aware kubectl evaluation.
2//!
3//! Distinguishes read-only subcommands (get, describe, logs) from mutating ones
4//! (apply, delete, scale). Supports env-gated auto-allow for subcommands
5//! like `apply` when specific environment variables match.
6
7use super::super::CommandSpec;
8use crate::config::KubectlConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use std::collections::HashMap;
11
12/// Subcommand-aware kubectl evaluator.
13///
14/// Evaluation order:
15/// 1. Read-only subcommands → ALLOW (with redirection escalation)
16/// 2. Env-gated subcommands → ALLOW if all `config_env` entries match, else ASK
17/// 3. Known mutating subcommands → ASK
18/// 4. Everything else → ASK
19pub struct KubectlSpec {
20    /// Subcommands that are always allowed (e.g. `get`, `describe`, `logs`).
21    read_only: Vec<String>,
22    /// Known mutating subcommands that always require confirmation.
23    mutating: Vec<String>,
24    /// Subcommands allowed only when all `config_env` entries match.
25    allowed_with_config: Vec<String>,
26    /// Required env var name→value pairs that gate `allowed_with_config` subcommands.
27    config_env: HashMap<String, String>,
28}
29
30impl KubectlSpec {
31    /// Build a kubectl spec from configuration.
32    pub fn from_config(config: &KubectlConfig) -> Self {
33        Self {
34            read_only: config.read_only.clone(),
35            mutating: config.mutating.clone(),
36            allowed_with_config: config.allowed_with_config.clone(),
37            config_env: config.config_env.clone(),
38        }
39    }
40
41    /// Extract the kubectl subcommand (first non-flag word after "kubectl").
42    /// Handles env var prefixes like `KUBECONFIG=~/.kube/staging kubectl apply`.
43    fn subcommand<'a>(ctx: &'a CommandContext) -> Option<&'a str> {
44        let mut iter = ctx.words.iter();
45        for word in iter.by_ref() {
46            if word == "kubectl" {
47                return iter.find(|w| !w.starts_with('-')).map(|s| s.as_str());
48            }
49        }
50        None
51    }
52
53    /// Format config_env keys for reason strings.
54    fn env_keys_display(&self) -> String {
55        let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
56        keys.sort();
57        keys.join(", ")
58    }
59}
60
61impl CommandSpec for KubectlSpec {
62    fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
63        let sub_str = Self::subcommand(ctx).unwrap_or("?");
64
65        if self.read_only.iter().any(|s| s == sub_str) {
66            if let Some(ref r) = ctx.redirection {
67                return RuleMatch {
68                    decision: Decision::Ask,
69                    reason: format!("kubectl {sub_str} with {}", r.description),
70                };
71            }
72            return RuleMatch {
73                decision: Decision::Allow,
74                reason: format!("read-only kubectl {sub_str}"),
75            };
76        }
77
78        // Env-gated subcommands: allowed only when all config_env entries match
79        if self.allowed_with_config.iter().any(|s| s == sub_str) {
80            if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
81                if let Some(ref r) = ctx.redirection {
82                    return RuleMatch {
83                        decision: Decision::Ask,
84                        reason: format!("kubectl {sub_str} with {}", r.description),
85                    };
86                }
87                return RuleMatch {
88                    decision: Decision::Allow,
89                    reason: format!("kubectl {sub_str} with {}", self.env_keys_display()),
90                };
91            }
92            return RuleMatch {
93                decision: Decision::Ask,
94                reason: format!("kubectl {sub_str} requires confirmation"),
95            };
96        }
97
98        if self.mutating.iter().any(|s| s == sub_str) {
99            return RuleMatch {
100                decision: Decision::Ask,
101                reason: format!("kubectl {sub_str} requires confirmation"),
102            };
103        }
104
105        RuleMatch {
106            decision: Decision::Ask,
107            reason: format!("kubectl {sub_str} requires confirmation"),
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::config::Config;
116
117    /// Clear `KUBECONFIG` from the process environment so the env-gate
118    /// fallback in `env_satisfies` doesn't interfere.  Requires nextest.
119    fn clear_kubectl_env() {
120        assert!(
121            std::env::var("NEXTEST").is_ok(),
122            "this test mutates process env and requires nextest (cargo nextest run)"
123        );
124        unsafe { std::env::remove_var("KUBECONFIG") };
125    }
126
127    fn spec() -> KubectlSpec {
128        KubectlSpec::from_config(&Config::default_config().kubectl)
129    }
130
131    fn eval(cmd: &str) -> Decision {
132        let s = spec();
133        let ctx = CommandContext::from_command(cmd);
134        s.evaluate(&ctx).decision
135    }
136
137    #[test]
138    fn allow_get() {
139        assert_eq!(eval("kubectl get pods"), Decision::Allow);
140    }
141
142    #[test]
143    fn allow_describe() {
144        assert_eq!(eval("kubectl describe svc foo"), Decision::Allow);
145    }
146
147    #[test]
148    fn allow_logs() {
149        assert_eq!(eval("kubectl logs pod/foo"), Decision::Allow);
150    }
151
152    #[test]
153    fn ask_apply() {
154        assert_eq!(eval("kubectl apply -f deploy.yaml"), Decision::Ask);
155    }
156
157    #[test]
158    fn ask_delete() {
159        assert_eq!(eval("kubectl delete pod foo"), Decision::Ask);
160    }
161
162    #[test]
163    fn redir_get() {
164        assert_eq!(eval("kubectl get pods > pods.txt"), Decision::Ask);
165    }
166
167    // ── Env-gated commands ──
168
169    fn spec_with_env_gate() -> KubectlSpec {
170        KubectlSpec::from_config(&KubectlConfig {
171            read_only: vec!["get".into(), "describe".into()],
172            mutating: vec!["delete".into()],
173            allowed_with_config: vec!["apply".into(), "rollout".into()],
174            config_env: HashMap::from([("KUBECONFIG".into(), "~/.kube/config.ai".into())]),
175        })
176    }
177
178    fn eval_with_env_gate(cmd: &str) -> Decision {
179        let s = spec_with_env_gate();
180        let ctx = CommandContext::from_command(cmd);
181        s.evaluate(&ctx).decision
182    }
183
184    #[test]
185    fn env_gate_apply_with_matching_value() {
186        assert_eq!(
187            eval_with_env_gate("KUBECONFIG=~/.kube/config.ai kubectl apply -f deploy.yaml"),
188            Decision::Allow
189        );
190    }
191
192    #[test]
193    fn env_gate_apply_with_wrong_value() {
194        assert_eq!(
195            eval_with_env_gate("KUBECONFIG=~/.kube/config kubectl apply -f deploy.yaml"),
196            Decision::Ask
197        );
198    }
199
200    #[test]
201    fn env_gate_apply_no_config() {
202        clear_kubectl_env();
203        assert_eq!(
204            eval_with_env_gate("kubectl apply -f deploy.yaml"),
205            Decision::Ask
206        );
207    }
208
209    #[test]
210    fn env_gate_get_still_readonly() {
211        // read_only commands don't need the env var
212        assert_eq!(eval_with_env_gate("kubectl get pods"), Decision::Allow);
213    }
214
215    #[test]
216    fn env_gate_delete_still_asks() {
217        // mutating commands not in allowed_with_config always ask
218        assert_eq!(
219            eval_with_env_gate("KUBECONFIG=~/.kube/config.ai kubectl delete pod foo"),
220            Decision::Ask
221        );
222    }
223}