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