cc_toolgate/commands/tools/
kubectl.rs1use super::super::CommandSpec;
8use crate::config::KubectlConfig;
9use crate::eval::{CommandContext, Decision, RuleMatch};
10use std::collections::HashMap;
11
12pub struct KubectlSpec {
20 read_only: Vec<String>,
22 mutating: Vec<String>,
24 allowed_with_config: Vec<String>,
26 config_env: HashMap<String, String>,
28}
29
30impl KubectlSpec {
31 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 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 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 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 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 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 assert_eq!(eval_with_env_gate("kubectl get pods"), Decision::Allow);
213 }
214
215 #[test]
216 fn env_gate_delete_still_asks() {
217 assert_eq!(
219 eval_with_env_gate("KUBECONFIG=~/.kube/config.ai kubectl delete pod foo"),
220 Decision::Ask
221 );
222 }
223}