1use rig::completion::ToolDefinition;
15use rig::tool::Tool;
16use serde::{Deserialize, Serialize};
17use serde_json::json;
18use std::path::PathBuf;
19
20use crate::analyzer::kubelint::{
21 KubelintConfig, LintResult, Severity, lint, lint_content, lint_file,
22};
23
24#[derive(Debug, Deserialize)]
26pub struct KubelintArgs {
27 #[serde(default)]
30 pub path: Option<String>,
31
32 #[serde(default)]
34 pub content: Option<String>,
35
36 #[serde(default)]
38 pub include: Vec<String>,
39
40 #[serde(default)]
42 pub exclude: Vec<String>,
43
44 #[serde(default)]
46 pub threshold: Option<String>,
47}
48
49#[derive(Debug, thiserror::Error)]
51#[error("Kubelint error: {0}")]
52pub struct KubelintError(String);
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct KubelintTool {
68 project_path: PathBuf,
69}
70
71impl KubelintTool {
72 pub fn new(project_path: PathBuf) -> Self {
73 Self { project_path }
74 }
75
76 fn parse_threshold(threshold: &str) -> Severity {
77 match threshold.to_lowercase().as_str() {
78 "error" => Severity::Error,
79 "warning" => Severity::Warning,
80 "info" => Severity::Info,
81 _ => Severity::Warning,
82 }
83 }
84
85 fn get_check_category(code: &str) -> &'static str {
87 match code {
88 "privileged-container"
90 | "privilege-escalation"
91 | "run-as-non-root"
92 | "read-only-root-fs"
93 | "drop-net-raw-capability"
94 | "hostnetwork"
95 | "hostpid"
96 | "hostipc"
97 | "host-mounts"
98 | "writable-host-mount"
99 | "docker-sock"
100 | "unsafe-proc-mount"
101 | "scc-deny-privileged-container" => "security",
102
103 "latest-tag"
105 | "no-liveness-probe"
106 | "no-readiness-probe"
107 | "unset-cpu-requirements"
108 | "unset-memory-requirements"
109 | "minimum-replicas"
110 | "no-anti-affinity"
111 | "no-rolling-update-strategy"
112 | "default-service-account"
113 | "deprecated-service-account"
114 | "env-var-secret"
115 | "read-secret-from-env-var"
116 | "priority-class-name"
117 | "no-node-affinity"
118 | "restart-policy"
119 | "sysctls"
120 | "dnsconfig-options" => "best-practice",
121
122 "access-to-secrets"
124 | "access-to-create-pods"
125 | "cluster-admin-role-binding"
126 | "wildcard-in-rules" => "rbac",
127
128 "dangling-service"
130 | "dangling-ingress"
131 | "dangling-horizontalpodautoscaler"
132 | "dangling-networkpolicy"
133 | "mismatching-selector"
134 | "duplicate-env-var"
135 | "invalid-target-ports"
136 | "non-existent-service-account"
137 | "non-isolated-pod"
138 | "use-namespace"
139 | "env-var-value-from"
140 | "job-ttl-seconds-after-finished" => "validation",
141
142 "ssh-port" | "privileged-ports" | "liveness-port" | "readiness-port"
144 | "startup-port" => "ports",
145
146 "pdb-max-unavailable" | "pdb-min-available" | "pdb-unhealthy-pod-eviction-policy" => {
148 "disruption-budget"
149 }
150
151 "hpa-minimum-replicas" => "autoscaling",
153
154 "no-extensions-v1beta" => "deprecated-api",
156
157 "service-type" => "service",
159
160 _ => "other",
161 }
162 }
163
164 fn get_priority(severity: Severity, code: &str) -> &'static str {
166 let category = Self::get_check_category(code);
167 match (severity, category) {
168 (Severity::Error, "security") => "critical",
169 (Severity::Error, "rbac") => "critical",
170 (Severity::Error, _) => "high",
171 (Severity::Warning, "security") => "high",
172 (Severity::Warning, "rbac") => "high",
173 (Severity::Warning, "validation") => "medium",
174 (Severity::Warning, "best-practice") => "medium",
175 (Severity::Warning, _) => "medium",
176 (Severity::Info, _) => "low",
177 }
178 }
179
180 fn format_result(result: &LintResult, source: &str) -> String {
182 let enriched_failures: Vec<serde_json::Value> = result
184 .failures
185 .iter()
186 .map(|f| {
187 let code = f.code.as_str();
188 let category = Self::get_check_category(code);
189 let priority = Self::get_priority(f.severity, code);
190
191 json!({
192 "check": code,
193 "severity": format!("{:?}", f.severity).to_lowercase(),
194 "priority": priority,
195 "category": category,
196 "message": f.message,
197 "object": {
198 "name": f.object_name,
199 "kind": f.object_kind,
200 "namespace": f.object_namespace,
201 },
202 "file": f.file_path.display().to_string(),
203 "line": f.line,
204 "remediation": f.remediation,
205 })
206 })
207 .collect();
208
209 let critical: Vec<_> = enriched_failures
211 .iter()
212 .filter(|f| f["priority"] == "critical")
213 .cloned()
214 .collect();
215 let high: Vec<_> = enriched_failures
216 .iter()
217 .filter(|f| f["priority"] == "high")
218 .cloned()
219 .collect();
220 let medium: Vec<_> = enriched_failures
221 .iter()
222 .filter(|f| f["priority"] == "medium")
223 .cloned()
224 .collect();
225 let low: Vec<_> = enriched_failures
226 .iter()
227 .filter(|f| f["priority"] == "low")
228 .cloned()
229 .collect();
230
231 let mut by_category: std::collections::HashMap<&str, usize> =
233 std::collections::HashMap::new();
234 for f in &result.failures {
235 let cat = Self::get_check_category(f.code.as_str());
236 *by_category.entry(cat).or_default() += 1;
237 }
238
239 let decision_context = if critical.is_empty() && high.is_empty() {
241 if medium.is_empty() && low.is_empty() {
242 "Kubernetes manifests follow security best practices. No issues found."
243 } else if medium.is_empty() {
244 "Minor improvements possible. Low priority issues only."
245 } else {
246 "Good baseline. Medium priority improvements recommended."
247 }
248 } else if !critical.is_empty() {
249 "CRITICAL security issues found. Fix before deployment to production."
250 } else {
251 "High priority issues found. Review security and best practice violations."
252 };
253
254 let mut output = json!({
256 "source": source,
257 "success": result.summary.passed,
258 "decision_context": decision_context,
259 "tool_guidance": "Use kubelint for K8s manifest security/best practices. Use helmlint for Helm chart structure/template syntax.",
260 "summary": {
261 "total_issues": result.failures.len(),
262 "objects_analyzed": result.summary.objects_analyzed,
263 "checks_run": result.summary.checks_run,
264 "by_priority": {
265 "critical": critical.len(),
266 "high": high.len(),
267 "medium": medium.len(),
268 "low": low.len(),
269 },
270 "by_category": by_category,
271 },
272 "action_plan": {
273 "critical": critical,
274 "high": high,
275 "medium": medium,
276 "low": low,
277 },
278 });
279
280 if !enriched_failures.is_empty() {
282 let quick_fixes: Vec<String> = enriched_failures
283 .iter()
284 .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
285 .take(5)
286 .map(|f| {
287 let remediation = f["remediation"]
288 .as_str()
289 .unwrap_or("Review the check documentation.");
290 format!(
291 "{}/{}: {} - {}",
292 f["object"]["kind"].as_str().unwrap_or(""),
293 f["object"]["name"].as_str().unwrap_or(""),
294 f["check"].as_str().unwrap_or(""),
295 remediation
296 )
297 })
298 .collect();
299
300 if !quick_fixes.is_empty() {
301 output["quick_fixes"] = json!(quick_fixes);
302 }
303 }
304
305 if !result.parse_errors.is_empty() {
306 output["parse_errors"] = json!(result.parse_errors);
307 }
308
309 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
310 }
311}
312
313impl Tool for KubelintTool {
314 const NAME: &'static str = "kubelint";
315
316 type Error = KubelintError;
317 type Args = KubelintArgs;
318 type Output = String;
319
320 async fn definition(&self, _prompt: String) -> ToolDefinition {
321 ToolDefinition {
322 name: Self::NAME.to_string(),
323 description: "Lint Kubernetes manifests for SECURITY and BEST PRACTICES. \
324 Works on raw YAML files, Helm charts (renders them first), and Kustomize directories. \
325 \n\n**IMPORTANT:** Always specify the `path` parameter to lint specific files or directories. \
326 \n\n**Use kubelint for:** Security issues (privileged containers, missing probes), \
327 resource best practices (limits, RBAC), manifest validation. \
328 \n**Use helmlint for:** Helm chart structure, template syntax, Chart.yaml/values.yaml validation. \
329 \n\nReturns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
330 and type (security/rbac/best-practice/validation). Each issue includes remediation steps."
331 .to_string(),
332 parameters: json!({
333 "type": "object",
334 "properties": {
335 "path": {
336 "type": "string",
337 "description": "Path to K8s manifest(s) relative to project root. Can be: \
338 single YAML file, directory with YAMLs, Helm chart directory, or Kustomize directory."
339 },
340 "content": {
341 "type": "string",
342 "description": "Inline YAML content to lint. Use this to validate generated manifests before writing."
343 },
344 "include": {
345 "type": "array",
346 "items": { "type": "string" },
347 "description": "Specific checks to run (e.g., ['privileged-container', 'latest-tag']). If empty, runs all default checks."
348 },
349 "exclude": {
350 "type": "array",
351 "items": { "type": "string" },
352 "description": "Checks to skip (e.g., ['no-liveness-probe', 'minimum-replicas'])"
353 },
354 "threshold": {
355 "type": "string",
356 "enum": ["error", "warning", "info"],
357 "description": "Minimum severity to report. Default is 'warning'."
358 }
359 }
360 }),
361 }
362 }
363
364 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
365 let mut config = KubelintConfig::default().with_all_builtin();
367
368 for check in &args.include {
370 config = config.include(check.as_str());
371 }
372
373 for check in &args.exclude {
375 config = config.exclude(check.as_str());
376 }
377
378 if let Some(threshold) = &args.threshold {
380 config = config.with_threshold(Self::parse_threshold(threshold));
381 }
382
383 let (result, source) = if let Some(content) = &args.content {
385 (lint_content(content, &config), "<inline>".to_string())
387 } else if let Some(path) = &args.path {
388 let full_path = self.project_path.join(path);
390
391 if !full_path.exists() {
392 return Err(KubelintError(format!(
393 "Path '{}' does not exist.",
394 full_path.display()
395 )));
396 }
397
398 if full_path.is_file() {
399 (lint_file(&full_path, &config), path.clone())
400 } else {
401 (lint(&full_path, &config), path.clone())
402 }
403 } else {
404 let candidates = [
406 "kubernetes",
407 "k8s",
408 "manifests",
409 "deploy",
410 "deployment",
411 "helm",
412 "charts",
413 "test-lint", "test-lint/k8s", ".",
416 ];
417
418 let mut found = None;
419 for candidate in &candidates {
420 let candidate_path = self.project_path.join(candidate);
421 if candidate_path.exists() {
422 if candidate_path.join("Chart.yaml").exists()
424 || candidate_path.join("kustomization.yaml").exists()
425 || candidate_path.join("kustomization.yml").exists()
426 {
427 found = Some((candidate_path, candidate.to_string()));
428 break;
429 }
430 if let Ok(entries) = std::fs::read_dir(&candidate_path) {
432 let has_yaml = entries.filter_map(|e| e.ok()).any(|e| {
433 e.path()
434 .extension()
435 .map(|ext| ext == "yaml" || ext == "yml")
436 .unwrap_or(false)
437 });
438 if has_yaml {
439 found = Some((candidate_path, candidate.to_string()));
440 break;
441 }
442 }
443 }
444 }
445
446 if let Some((path, name)) = found {
447 (lint(&path, &config), name)
448 } else {
449 return Err(KubelintError(
450 "No path specified and no K8s manifests found. \
451 Specify a path with 'path' parameter or provide 'content' to lint."
452 .to_string(),
453 ));
454 }
455 };
456
457 if !result.parse_errors.is_empty() {
459 log::warn!("K8s manifest parse errors: {:?}", result.parse_errors);
460 }
461
462 Ok(Self::format_result(&result, &source))
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::fs;
470 use tempfile::TempDir;
471
472 #[tokio::test]
473 async fn test_kubelint_inline_content() {
474 let temp_dir = TempDir::new().unwrap();
475 let tool = KubelintTool::new(temp_dir.path().to_path_buf());
476
477 let yaml = r#"
478apiVersion: apps/v1
479kind: Deployment
480metadata:
481 name: insecure-deploy
482spec:
483 replicas: 1
484 selector:
485 matchLabels:
486 app: test
487 template:
488 spec:
489 containers:
490 - name: nginx
491 image: nginx:latest
492 securityContext:
493 privileged: true
494"#;
495
496 let args = KubelintArgs {
497 path: None,
498 content: Some(yaml.to_string()),
499 include: vec!["privileged-container".to_string(), "latest-tag".to_string()],
500 exclude: vec![],
501 threshold: None,
502 };
503
504 let result = tool.call(args).await.unwrap();
505 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
506
507 assert!(parsed["summary"]["total_issues"].as_u64().unwrap_or(0) > 0);
509 assert!(parsed["decision_context"].is_string());
510 assert!(parsed["tool_guidance"].is_string());
511 }
512
513 #[tokio::test]
514 async fn test_kubelint_secure_deployment() {
515 let temp_dir = TempDir::new().unwrap();
516 let tool = KubelintTool::new(temp_dir.path().to_path_buf());
517
518 let yaml = r#"
519apiVersion: apps/v1
520kind: Deployment
521metadata:
522 name: secure-deploy
523spec:
524 replicas: 3
525 selector:
526 matchLabels:
527 app: test
528 template:
529 spec:
530 serviceAccountName: my-service-account
531 securityContext:
532 runAsNonRoot: true
533 containers:
534 - name: nginx
535 image: nginx:1.25.0
536 securityContext:
537 privileged: false
538 allowPrivilegeEscalation: false
539 readOnlyRootFilesystem: true
540 capabilities:
541 drop:
542 - ALL
543"#;
544
545 let args = KubelintArgs {
546 path: None,
547 content: Some(yaml.to_string()),
548 include: vec!["privileged-container".to_string(), "latest-tag".to_string()],
549 exclude: vec![],
550 threshold: None,
551 };
552
553 let result = tool.call(args).await.unwrap();
554 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
555
556 let critical = parsed["summary"]["by_priority"]["critical"]
558 .as_u64()
559 .unwrap_or(99);
560 let high = parsed["summary"]["by_priority"]["high"]
561 .as_u64()
562 .unwrap_or(99);
563 assert_eq!(critical, 0);
564 assert_eq!(high, 0);
565 }
566
567 #[tokio::test]
568 async fn test_kubelint_file() {
569 let temp_dir = TempDir::new().unwrap();
570 let manifest_path = temp_dir.path().join("deployment.yaml");
571
572 fs::write(
573 &manifest_path,
574 r#"apiVersion: apps/v1
575kind: Deployment
576metadata:
577 name: test
578spec:
579 replicas: 1
580 selector:
581 matchLabels:
582 app: test
583 template:
584 spec:
585 containers:
586 - name: nginx
587 image: nginx:1.25.0
588"#,
589 )
590 .unwrap();
591
592 let tool = KubelintTool::new(temp_dir.path().to_path_buf());
593 let args = KubelintArgs {
594 path: Some("deployment.yaml".to_string()),
595 content: None,
596 include: vec![],
597 exclude: vec![],
598 threshold: None,
599 };
600
601 let result = tool.call(args).await.unwrap();
602 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
603
604 assert!(
605 parsed["source"]
606 .as_str()
607 .unwrap()
608 .contains("deployment.yaml")
609 );
610 assert!(parsed["summary"]["objects_analyzed"].as_u64().unwrap_or(0) >= 1);
611 }
612
613 #[tokio::test]
614 async fn test_kubelint_output_format() {
615 let temp_dir = TempDir::new().unwrap();
616 let tool = KubelintTool::new(temp_dir.path().to_path_buf());
617
618 let yaml = r#"
619apiVersion: apps/v1
620kind: Deployment
621metadata:
622 name: insecure-deploy
623spec:
624 replicas: 1
625 selector:
626 matchLabels:
627 app: test
628 template:
629 spec:
630 containers:
631 - name: nginx
632 image: nginx:latest
633 securityContext:
634 privileged: true
635"#;
636
637 let args = KubelintArgs {
638 path: None,
639 content: Some(yaml.to_string()),
640 include: vec![], exclude: vec![],
642 threshold: None,
643 };
644
645 let result = tool.call(args).await.unwrap();
646 println!("\n=== KUBELINT OUTPUT ===\n{}\n", result);
647
648 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
649
650 assert!(
652 parsed["summary"]["total_issues"].as_u64().unwrap() > 0,
653 "Expected issues but got none. Output: {}",
654 result
655 );
656 assert!(
657 !parsed["action_plan"]["critical"]
658 .as_array()
659 .unwrap()
660 .is_empty()
661 || !parsed["action_plan"]["high"].as_array().unwrap().is_empty(),
662 "Expected critical or high priority issues"
663 );
664 }
665
666 #[tokio::test]
667 async fn test_kubelint_excludes() {
668 let temp_dir = TempDir::new().unwrap();
669 let tool = KubelintTool::new(temp_dir.path().to_path_buf());
670
671 let yaml = r#"
672apiVersion: apps/v1
673kind: Deployment
674metadata:
675 name: test
676spec:
677 replicas: 1
678 selector:
679 matchLabels:
680 app: test
681 template:
682 spec:
683 containers:
684 - name: nginx
685 image: nginx:latest
686 securityContext:
687 privileged: true
688"#;
689
690 let args = KubelintArgs {
691 path: None,
692 content: Some(yaml.to_string()),
693 include: vec![],
694 exclude: vec!["privileged-container".to_string(), "latest-tag".to_string()],
695 threshold: None,
696 };
697
698 let result = tool.call(args).await.unwrap();
699 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
700
701 let all_issues: Vec<_> = ["critical", "high", "medium", "low"]
703 .iter()
704 .flat_map(|p| {
705 parsed["action_plan"][p]
706 .as_array()
707 .cloned()
708 .unwrap_or_default()
709 })
710 .collect();
711
712 assert!(
713 !all_issues
714 .iter()
715 .any(|i| i["check"] == "privileged-container")
716 );
717 assert!(!all_issues.iter().any(|i| i["check"] == "latest-tag"));
718 }
719}