use super::super::CommandSpec;
use crate::config::KubectlConfig;
use crate::eval::{CommandContext, Decision, RuleMatch};
use agent_shell_parser::parse::Word;
use std::collections::HashMap;
pub struct KubectlSpec {
read_only: Vec<String>,
mutating: Vec<String>,
allowed_with_config: Vec<String>,
config_env: HashMap<String, String>,
}
impl KubectlSpec {
pub fn from_config(config: &KubectlConfig) -> Self {
Self {
read_only: config.read_only.clone(),
mutating: config.mutating.clone(),
allowed_with_config: config.allowed_with_config.clone(),
config_env: config.config_env.clone(),
}
}
fn subcommand(ctx: &CommandContext) -> Option<&Word> {
let mut iter = ctx.words.iter();
for word in iter.by_ref() {
if word == "kubectl" {
return iter.find(|w| !w.is_flag());
}
}
None
}
fn env_keys_display(&self) -> String {
let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
keys.sort();
keys.join(", ")
}
}
impl CommandSpec for KubectlSpec {
fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
let sub_str: &str = Self::subcommand(ctx).map(|w| w.as_str()).unwrap_or("?");
if self.read_only.iter().any(|s| s == sub_str) {
if let Some(ref r) = ctx.redirection {
return RuleMatch {
decision: Decision::Ask,
reason: format!("kubectl {sub_str} with {}", r),
};
}
return RuleMatch {
decision: Decision::Allow,
reason: format!("read-only kubectl {sub_str}"),
};
}
if self.allowed_with_config.iter().any(|s| s == sub_str) {
if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
if let Some(ref r) = ctx.redirection {
return RuleMatch {
decision: Decision::Ask,
reason: format!("kubectl {sub_str} with {}", r),
};
}
return RuleMatch {
decision: Decision::Allow,
reason: format!("kubectl {sub_str} with {}", self.env_keys_display()),
};
}
return RuleMatch {
decision: Decision::Ask,
reason: format!("kubectl {sub_str} requires confirmation"),
};
}
if self.mutating.iter().any(|s| s == sub_str) {
return RuleMatch {
decision: Decision::Ask,
reason: format!("kubectl {sub_str} requires confirmation"),
};
}
RuleMatch {
decision: Decision::Ask,
reason: format!("kubectl {sub_str} requires confirmation"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn clear_kubectl_env() {
assert!(
std::env::var("NEXTEST").is_ok(),
"this test mutates process env and requires nextest (cargo nextest run)"
);
unsafe { std::env::remove_var("KUBECONFIG") };
}
fn spec() -> KubectlSpec {
KubectlSpec::from_config(&Config::default_config().kubectl)
}
fn eval(cmd: &str) -> Decision {
let s = spec();
let ctx = CommandContext::from_command(cmd);
s.evaluate(&ctx).decision
}
#[test]
fn allow_get() {
assert_eq!(eval("kubectl get pods"), Decision::Allow);
}
#[test]
fn allow_describe() {
assert_eq!(eval("kubectl describe svc foo"), Decision::Allow);
}
#[test]
fn allow_logs() {
assert_eq!(eval("kubectl logs pod/foo"), Decision::Allow);
}
#[test]
fn ask_apply() {
assert_eq!(eval("kubectl apply -f deploy.yaml"), Decision::Ask);
}
#[test]
fn ask_delete() {
assert_eq!(eval("kubectl delete pod foo"), Decision::Ask);
}
#[test]
fn redir_get() {
assert_eq!(eval("kubectl get pods > pods.txt"), Decision::Ask);
}
fn spec_with_env_gate() -> KubectlSpec {
KubectlSpec::from_config(&KubectlConfig {
read_only: vec!["get".into(), "describe".into()],
mutating: vec!["delete".into()],
allowed_with_config: vec!["apply".into(), "rollout".into()],
config_env: HashMap::from([("KUBECONFIG".into(), "~/.kube/config.ai".into())]),
})
}
fn eval_with_env_gate(cmd: &str) -> Decision {
let s = spec_with_env_gate();
let ctx = CommandContext::from_command(cmd);
s.evaluate(&ctx).decision
}
#[test]
fn env_gate_apply_with_matching_value() {
assert_eq!(
eval_with_env_gate("KUBECONFIG=~/.kube/config.ai kubectl apply -f deploy.yaml"),
Decision::Allow
);
}
#[test]
fn env_gate_apply_with_wrong_value() {
assert_eq!(
eval_with_env_gate("KUBECONFIG=~/.kube/config kubectl apply -f deploy.yaml"),
Decision::Ask
);
}
#[test]
fn env_gate_apply_no_config() {
clear_kubectl_env();
assert_eq!(
eval_with_env_gate("kubectl apply -f deploy.yaml"),
Decision::Ask
);
}
#[test]
fn env_gate_get_still_readonly() {
assert_eq!(eval_with_env_gate("kubectl get pods"), Decision::Allow);
}
#[test]
fn env_gate_delete_still_asks() {
assert_eq!(
eval_with_env_gate("KUBECONFIG=~/.kube/config.ai kubectl delete pod foo"),
Decision::Ask
);
}
}