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