syncable_cli/agent/tools/
kubelint.rs

1//! Kubelint tool - Native Kubernetes manifest linting using Rig's Tool trait
2//!
3//! Lints **rendered Kubernetes manifests** for security and best practices.
4//! Works on raw YAML files, Helm charts (renders them), and Kustomize directories.
5//!
6//! **Use this for:** Security issues, K8s resource best practices, RBAC, probes, resource limits.
7//! **Use HelmlintTool for:** Helm chart structure, template syntax, Chart.yaml validation.
8//!
9//! Output is optimized for AI agent decision-making with:
10//! - Categorized issues (security, best-practice, validation, rbac)
11//! - Priority rankings (critical, high, medium, low)
12//! - Actionable remediation recommendations
13
14use 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/// Arguments for the kubelint tool
26#[derive(Debug, Deserialize)]
27pub struct KubelintArgs {
28    /// Path to K8s manifest file or directory (relative to project root)
29    /// Can be: YAML file, directory with YAMLs, Helm chart dir, Kustomize dir
30    #[serde(default)]
31    pub path: Option<String>,
32
33    /// Inline YAML content to lint (alternative to path)
34    #[serde(default)]
35    pub content: Option<String>,
36
37    /// Checks to include (if empty, uses defaults)
38    #[serde(default)]
39    pub include: Vec<String>,
40
41    /// Checks to exclude
42    #[serde(default)]
43    pub exclude: Vec<String>,
44
45    /// Minimum severity threshold: "error", "warning", "info"
46    #[serde(default)]
47    pub threshold: Option<String>,
48}
49
50/// Error type for kubelint tool
51#[derive(Debug, thiserror::Error)]
52#[error("Kubelint error: {0}")]
53pub struct KubelintError(String);
54
55/// Tool to lint Kubernetes manifests natively
56///
57/// **When to use:**
58/// - Checking security issues (privileged containers, missing probes, etc.)
59/// - Validating K8s resource best practices
60/// - RBAC configuration validation
61/// - Resource limits and requests checking
62///
63/// **When to use HelmlintTool instead:**
64/// - Helm chart structure validation (Chart.yaml, values.yaml)
65/// - Go template syntax checking
66/// - Helm-specific best practices
67#[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    /// Get category for a check code
87    fn get_check_category(code: &str) -> &'static str {
88        match code {
89            // Security checks
90            "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            // Best practice checks
105            "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            // RBAC checks
124            "access-to-secrets"
125            | "access-to-create-pods"
126            | "cluster-admin-role-binding"
127            | "wildcard-in-rules" => "rbac",
128
129            // Validation checks
130            "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            // Port checks
144            "ssh-port" | "privileged-ports" | "liveness-port" | "readiness-port"
145            | "startup-port" => "ports",
146
147            // PDB checks
148            "pdb-max-unavailable" | "pdb-min-available" | "pdb-unhealthy-pod-eviction-policy" => {
149                "disruption-budget"
150            }
151
152            // HPA checks
153            "hpa-minimum-replicas" => "autoscaling",
154
155            // Deprecated API checks
156            "no-extensions-v1beta" => "deprecated-api",
157
158            // Service checks
159            "service-type" => "service",
160
161            _ => "other",
162        }
163    }
164
165    /// Get priority based on severity and check code
166    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    /// Format result optimized for agent decision-making
182    fn format_result(result: &LintResult, source: &str) -> String {
183        // Categorize and enrich failures
184        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        // Group by priority
211        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        // Group by category
233        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        // Build decision context
241        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        // Build agent-optimized output
256        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        // Add quick fixes summary
282        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        // Use smart compression with RAG retrieval pattern
311        // This preserves all data while keeping context size manageable
312        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        // Build configuration
370        let mut config = KubelintConfig::default().with_all_builtin();
371
372        // Apply includes
373        for check in &args.include {
374            config = config.include(check.as_str());
375        }
376
377        // Apply excludes
378        for check in &args.exclude {
379            config = config.exclude(check.as_str());
380        }
381
382        // Apply threshold
383        if let Some(threshold) = &args.threshold {
384            config = config.with_threshold(Self::parse_threshold(threshold));
385        }
386
387        // Determine source and lint
388        // IMPORTANT: Treat empty content as None - this fixes the issue where
389        // AI agents pass empty strings and the tool lints nothing instead of the path
390        let (result, source) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
391            // Lint non-empty inline content
392            (
393                lint_content(args.content.as_ref().unwrap(), &config),
394                "<inline>".to_string(),
395            )
396        } else if let Some(path) = &args.path {
397            // Lint file or directory
398            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            // Look for common K8s manifest locations
414            let candidates = [
415                "kubernetes",
416                "k8s",
417                "manifests",
418                "deploy",
419                "deployment",
420                "helm",
421                "charts",
422                "test-lint",     // For testing
423                "test-lint/k8s", // For testing
424                ".",
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                    // Check if it has YAML files or is a Helm/Kustomize directory
432                    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                    // Check for YAML files
440                    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        // Check for parse errors
467        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        // Should find issues
517        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        // Should pass for privileged and latest-tag checks
566        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![], // Use all defaults + builtin
650            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        // Verify structure
660        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        // Excluded checks should not appear
711        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}