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