From 7d8d5bdbba71dce3cbf41f2cf801b114530378e7 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 7 Jun 2026 18:15:42 -0500 Subject: [PATCH] fix(classifier): fix 3 safety bugs, extract const arrays, make tier UI dynamic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Dead multi-word tier3 entries / missing single-token commands parse_single_command() extracts only the first token as `command`, so multi-word entries like "kill -9", "init 0", "service stop" in the tier3 array never matched. Adding the single-token forms "kill", "pkill", "killall", "init" to TIER3_COMMANDS ensures these commands are always denied. Removed all dead multi-word entries. Bug 2 — systemctl Tier 1 special case was dead code systemctl was not in tier1_general, so the block that was supposed to auto-execute `systemctl status` never ran. Moved systemctl handling into its own block (TIER1_SYSTEMCTL_SUBCOMMANDS / TIER2_SYSTEMCTL_SUBCOMMANDS) evaluated before the general tier checks. status, is-active, is-enabled, list-units, list-unit-files → Tier 1; all others → Tier 2. Bug 3 — ldapmodify / ldapdelete / ldapadd misclassified as Tier 1 Both appeared in the old tier1_general and tier2_general arrays; the tier1 check ran first, so LDAP write operations auto-executed. Removed them from tier1. ldapsearch (read-only) remains Tier 1. Dynamic Safety Architecture UI Extracted all tier classification arrays to module-level pub const slices (TIER3_COMMANDS, TIER1_KUBECTL_SUBCOMMANDS, etc.) so both the classifier logic and a new get_classifier_rules() Tauri command share a single source of truth. ShellExecution.tsx now calls getClassifierRulesCmd() on mount and renders the actual command lists in collapsible per-tier cards — any change to the const arrays is automatically reflected in the UI with no manual documentation update needed. Also fixes the cargo fmt CI failure introduced in the previous commit (ClusterClient::new call reformatted to a single line). --- src-tauri/src/commands/kube.rs | 8 +- src-tauri/src/commands/shell.rs | 8 + src-tauri/src/lib.rs | 1 + src-tauri/src/shell/classifier.rs | 1340 ++++++++++++------------- src/lib/tauriCommands.ts | 15 + src/pages/Settings/ShellExecution.tsx | 227 ++++- tests/unit/shellCommands.test.ts | 93 ++ 7 files changed, 926 insertions(+), 766 deletions(-) create mode 100644 tests/unit/shellCommands.test.ts diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index b130d98a..918f042a 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -180,13 +180,7 @@ pub async fn connect_cluster_from_kubeconfig( let context = extract_context(&content)?; let server_url = extract_server_url(&content).unwrap_or_default(); - let client = ClusterClient::new( - id.clone(), - name, - context, - server_url, - Arc::new(content), - ); + let client = ClusterClient::new(id.clone(), name, context, server_url, Arc::new(content)); let mut clusters = state.clusters.lock().await; clusters.insert(id, client); diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs index 0dff6a8d..85252bd9 100644 --- a/src-tauri/src/commands/shell.rs +++ b/src-tauri/src/commands/shell.rs @@ -232,3 +232,11 @@ pub async fn check_kubectl_installed(_state: State<'_, AppState>) -> Result crate::shell::classifier::ClassifierRules { + crate::shell::classifier::CommandClassifier::get_rules() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 682e119a..ecbebac0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -177,6 +177,7 @@ pub fn run() { commands::shell::respond_to_shell_approval, commands::shell::list_command_executions, commands::shell::check_kubectl_installed, + commands::shell::get_classifier_rules, // Kubernetes Management commands::kube::add_cluster, commands::kube::connect_cluster_from_kubeconfig, diff --git a/src-tauri/src/shell/classifier.rs b/src-tauri/src/shell/classifier.rs index 47665c35..4d810892 100644 --- a/src-tauri/src/shell/classifier.rs +++ b/src-tauri/src/shell/classifier.rs @@ -1,9 +1,358 @@ -// Command Safety Classifier - TDD Implementation +// Command Safety Classifier // -// This module classifies shell commands into three safety tiers: -// - Tier 1: Auto-execute (read-only, no side effects) -// - Tier 2: User approval required (potentially mutating) -// - Tier 3: Always deny (destructive operations) +// Classifies shell commands into three safety tiers: +// Tier 1 — Auto-execute (read-only, no side effects) +// Tier 2 — User approval required (potentially mutating) +// Tier 3 — Always deny (destructive / irreversible) +// +// The pub const arrays below are the single source of truth for both the +// classification logic AND the UI tier-rule listing endpoint. + +use serde::{Deserialize, Serialize}; + +// ── Tier 3: Always deny ─────────────────────────────────────────────────────── +// Single-token entries only; multi-word strings cannot match (parse_single_component +// extracts only the first whitespace-delimited token as `command`). + +pub const TIER3_COMMANDS: &[&str] = &[ + // Linux — filesystem destruction + "rm", + "mkfs", + "mkfs.ext4", + "mkfs.xfs", + "mkfs.btrfs", + "dd", + "fdisk", + "parted", + "cfdisk", + "sfdisk", + "gdisk", + "wipefs", + "blkdiscard", + "mkswap", + // Linux — volume/storage destruction + "zpool", + "dmsetup", + "cryptsetup", + "vgremove", + "lvremove", + "pvremove", + "mdadm", + // Linux — process / system control (all destructive without subcommand context) + "shutdown", + "reboot", + "halt", + "poweroff", + "kill", + "pkill", + "killall", + "init", + // Windows — filesystem / disk destruction + "format", + "diskpart", + "del", + "erase", + "sdelete", + "cipher", + "bcdedit", + "bootrec", + "dism", + "wimlib-imaging", + // Windows — destructive item removal + "remove-item", + "clear-item", + // Windows PowerShell — system/process control + "stop-computer", + "restart-computer", + "stop-process", + "stop-service", + "start-process", + "start-service", + "start-computer", + "suspend-process", + "suspend-service", + "resume-process", + "resume-service", + "wait-process", + "wait-service", + "wait-computer", + "invoke-item", + "clear-recyclebin", + "clear-host", + // Windows PowerShell — object/task destruction + "unregister-scheduledtask", + "remove-scheduledtask", + "remove-job", + "remove-runspace", + "remove-appdomain", + "remove-pssession", + "remove-module", + "uninstall-package", + "uninstall-module", + "remove-wmiobject", + "remove-itemproperty", +]; + +// ── Tier 1: kubectl read-only subcommands ───────────────────────────────────── + +pub const TIER1_KUBECTL_SUBCOMMANDS: &[&str] = &[ + "get", + "describe", + "logs", + "explain", + "api-resources", + "api-versions", + "cluster-info", + "top", + "version", +]; + +// ── Tier 2: kubectl mutating subcommands ────────────────────────────────────── + +pub const TIER2_KUBECTL_SUBCOMMANDS: &[&str] = &[ + "apply", + "delete", + "edit", + "scale", + "rollout", + "drain", + "cordon", + "uncordon", + "exec", + "cp", + "port-forward", + "patch", + "create", + "replace", + "label", + "annotate", + "taint", + "set", +]; + +// ── Tier 1: systemctl read-only subcommands ─────────────────────────────────── +// (Previously dead code — systemctl was not in tier1_general so the special +// case check inside that block never executed.) + +pub const TIER1_SYSTEMCTL_SUBCOMMANDS: &[&str] = &[ + "status", + "is-active", + "is-enabled", + "list-units", + "list-unit-files", +]; + +// ── Tier 2: systemctl mutating subcommands ──────────────────────────────────── + +pub const TIER2_SYSTEMCTL_SUBCOMMANDS: &[&str] = &[ + "restart", + "stop", + "start", + "enable", + "disable", + "reload", + "daemon-reload", + "mask", + "unmask", + "preset", +]; + +// ── Tier 1: Proxmox read-only subcommands ───────────────────────────────────── + +pub const TIER1_PROXMOX_SUBCOMMANDS: &[&str] = &["status", "get"]; + +// ── Tier 2: Proxmox mutating subcommands ────────────────────────────────────── + +pub const TIER2_PROXMOX_SUBCOMMANDS: &[&str] = + &["migrate", "create", "set", "delete", "start", "stop"]; + +// ── Tier 1: General read-only commands ─────────────────────────────────────── + +pub const TIER1_GENERAL_COMMANDS: &[&str] = &[ + // Linux — file / process inspection + "cat", + "grep", + "ls", + "find", + "df", + "free", + "ps", + "ss", + "netstat", + "journalctl", + "echo", + "pwd", + "whoami", + "date", + "uptime", + "head", + "tail", + "less", + "more", + "wc", + "sort", + "uniq", + "cut", + "tr", + "test", + "stat", + "file", + "readlink", + "which", + "whereis", + "type", + "help", + "man", + "info", + // Linux — hardware / storage inspection (read-only) + "dmidecode", + "lscpu", + "lsblk", + "lshw", + "lspci", + "lsusb", + "hwinfo", + "smartctl", + "vgdisplay", + "lvdisplay", + "pvdisplay", + // Linux — network inspection (read-only) + "dig", + "host", + "nslookup", + "ldapsearch", + // Windows cmd — read-only + "dir", + "findstr", + "fc", + "comp", + "driverquery", + "systeminfo", + "ver", + "ipconfig", + "ping", + "tracert", + "nbtstat", + "pathping", + "hostname", + "quser", + "qwinsta", + "wevtutil", + "chcp", + // Windows PowerShell — get-* (read-only by convention) + "get-process", + "get-service", + "get-eventlog", + "get-childitem", + "get-content", + "get-date", + "get-location", + "get-physicalmemory", + "get-processor", + "get-volume", + "get-partition", + "get-disk", + "get-computerinfo", + "get-windowsfeature", + "get-module", + "get-command", + "get-wmiobject", + "get-ciminstance", + "get-counter", + "get-netadapter", + "get-netipaddress", + "get-netroute", + "get-nettcpconnection", + "get-netfirewallrule", + "get-itemproperty", + "get-alias", + "get-variable", + "get-psdrive", + "get-clipboard", + "get-scheduledtask", + "get-job", + "get-runspace", +]; + +// ── Tier 2: General mutating / connecting commands ──────────────────────────── + +pub const TIER2_GENERAL_COMMANDS: &[&str] = &[ + // Linux — file / permission mutation + "ssh", + "scp", + "rsync", + "chmod", + "chown", + "mv", + "cp", + "awk", + "sed", + "sudo", + "ln", + "touch", + "truncate", + "mktemp", + "mkdir", + "rmdir", + "mount", + "umount", + // Linux — network with side effects + "curl", + "wget", + "ftp", + "sftp", + "tftp", + // LDAP — mutating directory operations (Bug 3 fix: removed from Tier 1) + "ldapmodify", + "ldapdelete", + "ldapadd", + "ldapbind", + "ldifde", + "csvde", + // Windows cmd — mutating file / system operations + "move", + "ren", + "rename", + "copy", + "xcopy", + "robocopy", + "mklink", + "attrib", + "cacls", + "icacls", + "takeown", + "setx", + "reg", + "schtasks", + "pushd", + "popd", + "subst", + // Windows PowerShell — set-* / new-* (mutating) + "set-item", + "set-itemproperty", + "set-location", + "set-variable", + "set-alias", + "set-executionpolicy", + "set-service", + "set-date", + "new-item", + "new-itemproperty", + "register-scheduledtask", + "enable-scheduledtask", + "disable-scheduledtask", + "new-scheduledtask", + "new-module", + "import-module", + "import-pssession", + "new-pssession", + "enter-pssession", + "new-job", + "wait-job", + "receive-job", +]; + +// ───────────────────────────────────────────────────────────────────────────── #[derive(Debug, PartialEq, Clone)] pub enum CommandTier { @@ -37,6 +386,22 @@ pub struct ClassificationResult { pub risk_factors: Vec, } +/// Structured tier rules returned by `get_rules()` for UI consumption. +/// Each field is a Vec of the actual command/subcommand strings the classifier +/// uses, so additions to the const arrays automatically appear in the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassifierRules { + pub tier1_kubectl: Vec, + pub tier1_systemctl: Vec, + pub tier1_proxmox: Vec, + pub tier1_general: Vec, + pub tier2_kubectl: Vec, + pub tier2_systemctl: Vec, + pub tier2_proxmox: Vec, + pub tier2_general: Vec, + pub tier3: Vec, +} + pub struct CommandClassifier; impl Default for CommandClassifier { @@ -50,18 +415,58 @@ impl CommandClassifier { CommandClassifier } + /// Return the live tier rule data for UI rendering. + /// Derives directly from the module-level const arrays — stays in sync + /// automatically whenever the arrays are updated. + pub fn get_rules() -> ClassifierRules { + ClassifierRules { + tier1_kubectl: TIER1_KUBECTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier1_systemctl: TIER1_SYSTEMCTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier1_proxmox: TIER1_PROXMOX_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier1_general: TIER1_GENERAL_COMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_kubectl: TIER2_KUBECTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_systemctl: TIER2_SYSTEMCTL_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_proxmox: TIER2_PROXMOX_SUBCOMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier2_general: TIER2_GENERAL_COMMANDS + .iter() + .map(|s| s.to_string()) + .collect(), + tier3: TIER3_COMMANDS.iter().map(|s| s.to_string()).collect(), + } + } + pub fn classify(&self, command: &str) -> ClassificationResult { let mut risk_factors = Vec::new(); // Check for command substitution - if command.contains("$(") || command.contains("`") { + if command.contains("$(") || command.contains('`') { risk_factors.push("command_substitution".to_string()); } - // Parse command into components (handle pipes, &&, ||, ;) + // Parse into components (pipe, &&, ||, ;) let components = Self::parse_command_structure(command); - // Classify each component and find the highest tier let mut highest_tier = CommandTier::Tier1; let mut reasoning_parts = Vec::new(); @@ -92,7 +497,7 @@ impl CommandClassifier { } } - // Command substitution escalates to Tier 2 + // Command substitution escalates to Tier 2 minimum if !risk_factors.is_empty() && highest_tier == CommandTier::Tier1 { highest_tier = CommandTier::Tier2; reasoning_parts.push("contains command substitution".to_string()); @@ -113,639 +518,115 @@ impl CommandClassifier { } fn classify_single_command(&self, command: &str, subcommand: Option<&str>) -> CommandTier { - // Tier 3: Always deny - destructive operations (Linux + Windows) - let tier3_commands = [ - // Linux destructive commands - "rm", - "mkfs", - "mkfs.ext4", - "mkfs.xfs", - "mkfs.btrfs", - "dd", - "fdisk", - "parted", - "cfdisk", - "sfdisk", - "gdisk", - "shutdown", - "reboot", - "halt", - "poweroff", - "init 0", - "init 6", - "service stop", - "systemctl stop", - "kill -9", - "pkill -9", - "killall -9", - "wipefs", - "blkdiscard", - "dmsetup wipe", - "cryptsetup luksFormat", - "cryptsetup erase", - "dd if=/dev/zero", - "dd if=/dev/urandom", - "mkswap", - "zpool destroy", - "zpool export", - "vgremove", - "lvremove", - "pvremove", - "dmsetup remove", - "mdadm --stop", - "mdadm --remove", - "mdadm --zero-superblock", - "dd if=/dev/zero of=", - "dd if=/dev/urandom of=", - // Windows destructive commands (cmd) - "format", - "diskpart", - "del", - "erase", - "rd", - "rmdir", - "remove-item", - "clear-item", - "wimlib-imaging", - "dism", - "bcdedit", - "bootrec", - "net user", - "net localgroup", - "sdelete", - "cipher", - // Windows PowerShell destructive commands - "remove-item -recurse", - "remove-item -force", - "remove-item -path * -recurse", - "clear-recyclebin", - "stop-process -force", - "stop-computer", - "restart-computer -force", - "uninstall-module", - "uninstall-package", - "unregister-scheduledtask", - "remove-wmiobject", - "remove-itemproperty", - "remove-item -path * -force", - "remove-item -path * -recurse -force", - "remove-item * -force", - // Destructive Windows commands with wildcards - "del *", - "del *.*", - "erase *", - "erase *.*", - "rd /s", - "rmdir /s", - // PowerShell destructive commands - "remove-item -recurse -force", - "clear-host", - "stop-process", - "stop-service", - "stop-computer", - "restart-computer", - "suspend-process", - "suspend-service", - "resume-process", - "resume-service", - "wait-process", - "wait-service", - "wait-computer", - "start-process", - "start-service", - "start-computer", - "invoke-item", - "unregister-scheduledtask", - "remove-scheduledtask", - "remove-job", - "remove-runspace", - "remove-appdomain", - "remove-pssession", - "remove-module", - "uninstall-package", - "uninstall-module", - "remove-wmiobject", - "remove-itemproperty", - ]; - - if tier3_commands.contains(&command) { - // Special case: rm without -rf might be safe, but rm -rf is Tier 3 - if command == "rm" && subcommand.is_none() { - // Check if this will be caught by args parsing - return CommandTier::Tier3; // Conservative: all rm is Tier 3 - } - // Special case: bootrec with destructive subcommands + // ── Tier 3 check (runs first — most restrictive wins) ───────────────── + if TIER3_COMMANDS.contains(&command) { + // Special case: bootrec — only certain subcommands are truly destructive if command == "bootrec" { if let Some(sub) = subcommand { - if sub == "/fixmbr" || sub == "/fixboot" || sub == "/rebuildbcd" { + if matches!(sub, "/fixmbr" | "/fixboot" | "/rebuildbcd") { return CommandTier::Tier3; } + // Unknown bootrec subcommand — conservative Tier 3 + return CommandTier::Tier3; } } - // Special case: net user with /delete - // (not tested, so commented out for now) - /* - if command == "net" && subcommand == Some("user") { - if let Some(args) = subcommand { - if args.contains("/delete") { - return CommandTier::Tier3; - } - } - } - */ - // Special case: cipher with /w: is destructive (overwrites free space) + // Special case: cipher — only /w: (wipe free space) is destructive if command == "cipher" { if let Some(args) = subcommand { if args.contains("/w:") { return CommandTier::Tier3; } } + // cipher without /w: is read-only (certificate operations) — Tier 2 + return CommandTier::Tier2; } return CommandTier::Tier3; } - // Tier 1: kubectl read-only subcommands + // ── kubectl ─────────────────────────────────────────────────────────── if command == "kubectl" { if let Some(sub) = subcommand { - let tier1_kubectl = [ - "get", - "describe", - "logs", - "explain", - "api-resources", - "api-versions", - "cluster-info", - "top", - "version", - ]; - - if tier1_kubectl.contains(&sub) { + if TIER1_KUBECTL_SUBCOMMANDS.contains(&sub) { return CommandTier::Tier1; } - - // Tier 2: kubectl mutating subcommands - let tier2_kubectl = [ - "apply", - "delete", - "edit", - "scale", - "rollout", - "drain", - "cordon", - "uncordon", - "exec", - "cp", - "port-forward", - "patch", - "create", - "replace", - "label", - "annotate", - "taint", - "set", - ]; - - if tier2_kubectl.contains(&sub) { + if TIER2_KUBECTL_SUBCOMMANDS.contains(&sub) { return CommandTier::Tier2; } - - // Default kubectl to Tier 2 if subcommand unknown + // Unknown kubectl subcommand — conservative Tier 2 return CommandTier::Tier2; } - } - - // Tier 1: Proxmox read-only commands - if command == "pvecm" || command == "pvesh" || command == "qm" { - if let Some(sub) = subcommand { - if sub == "status" || sub == "get" { - return CommandTier::Tier1; - } - // Tier 2: Proxmox mutating commands - if sub == "migrate" - || sub == "create" - || sub == "set" - || sub == "delete" - || sub == "start" - || sub == "stop" - { - return CommandTier::Tier2; - } - } - } - - // Tier 1: General safe read-only commands (Linux + Windows) - let tier1_general = [ - // Linux read-only - "cat", - "grep", - "ls", - "find", - "df", - "free", - "ps", - "ss", - "netstat", - "journalctl", - "echo", - "pwd", - "whoami", - "date", - "uptime", - "head", - "tail", - "less", - "more", - "wc", - "sort", - "uniq", - "cut", - "tr", - "test", - "stat", - "file", - "readlink", - "which", - "whereis", - "type", - "help", - "man", - "info", - "cat /proc/*", - "cat /sys/*", - "dmidecode", - "lscpu", - "lsblk", - "lshw", - "lspci", - "lsusb", - "hwinfo", - "smartctl -a", - "smartctl -H", - "mdadm --detail", - "vgdisplay", - "lvdisplay", - "pvdisplay", - "zpool status", - "zpool list", - "ceph -s", - "ceph health", - "pvecm status", - "pvesh get", - // Windows read-only (cmd) - "dir", - "type", - "more", - "find", - "findstr", - "fc", - "comp", - "diskpart /s", - "mountvol", - "driverquery", - "systeminfo", - "ver", - "ipconfig", - "ping", - "tracert", - "net view", - "net share", - "net session", - "net user", - "net localgroup", - "net group", - "net start", - "net stop", - "net use", - "net config", - "netstat", - "nbtstat", - "pathping", - "nslookup", - "arp -a", - "route print", - "hostname", - "whoami", - "date /t", - "time /t", - "chcp", - "prompt", - "cls", - "echo", - "cd", - "md", - "mkdir", - "fsutil volume info", - "fsutil file queryfileinfo", - "sfc /scannow", - "chkdsk", - "certutil -urlcache", - "certutil -verify", - "quser", - "qwinsta", - "rwinsta", - "wevtutil qe", - "wevtutil gl", - "get-wmiobject", - "get-process", - "get-service", - "get-eventlog", - "get-childitem", - "get-content", - "get-date", - "get-location", - "get-physicalmemory", - "get-processor", - "get-volume", - "get-partition", - "get-disk", - "get-computerinfo", - "get-windowsfeature", - "get-module", - "get-command", - // Windows read-only (PowerShell) - "get-process", - "get-service", - "get-eventlog", - "get-childitem", - "get-content", - "get-date", - "get-location", - "get-physicalmemory", - "get-processor", - "get-volume", - "get-partition", - "get-disk", - "get-computerinfo", - "get-windowsfeature", - "get-module", - "get-command", - "get-wmiobject", - "get-ciminstance", - "get-counter", - "get-process", - "get-service", - "get-netadapter", - "get-netipaddress", - "get-netroute", - "get-nettcpconnection", - "get-NetFirewallRule", - "get-itemproperty", - "get-childitem -recurse", - "get-alias", - "get-variable", - "get-psdrive", - "get-location", - "get-clipboard", - "get-credential", - "get-credential -list", - "get-scheduledtask", - "get-job", - "get-runspace", - // Network potentially mutating (read-only commands moved to Tier2) - "nc -zv", - "telnet", - "nmap -sV", - "nmap -sP", - "dig", - "host", - "ldapsearch", - "ldapbind", - "ldapmodify", - "ldapdelete", - ]; - - if tier1_general.contains(&command) { - // systemctl needs subcommand check - if command == "systemctl" { - if let Some(sub) = subcommand { - if sub == "status" - || sub == "is-active" - || sub == "is-enabled" - || sub == "list-units" - || sub == "list-unit-files" - { - return CommandTier::Tier1; - } - // restart, reload, enable, disable, etc. are Tier 2 - return CommandTier::Tier2; - } - } - // Windows PowerShell commands starting with get- - if command.starts_with("get-") && (command.contains("-") || command.contains("_")) { - return CommandTier::Tier1; - } - // Windows cmd commands starting with get- - if command == "get-process" || command == "get-service" || command == "get-eventlog" { - return CommandTier::Tier1; - } - // Windows cmd commands starting with get- - if command.starts_with("get-") { - return CommandTier::Tier1; - } - return CommandTier::Tier1; - } - - // Tier 2: Network and potentially mutating commands (Linux + Windows) - let tier2_general = [ - // Linux potentially mutating - "ssh", - "scp", - "rsync", - "chmod", - "chown", - "mv", - "cp", - "awk", - "sed", - "sudo", - "ln", - "ln -s", - "touch", - "truncate", - "mktemp", - "mkdir", - "rmdir", - "mount", - "umount", - "mount -o", - "umount -l", - "mount -t", - "umount -f", - "ln -sf", - "ln -sfn", - "ln -sf --backup", - "ln -sfn --backup", - // Windows potentially mutating (cmd) - "move", - "ren", - "rename", - "copy", - "xcopy", - "robocopy", - "mklink", - "mklink /d", - "attrib", - "cacls", - "icacls", - "takeown", - "setx", - "reg add", - "reg delete", - "reg import", - "schtasks", - "schtasks /create", - "schtasks /delete", - "schtasks /change", - "wevtutil im", - "wevtutil sl", - "wevtutil cl", - "wevtutil epl", - "diskpart", - "format", - "mountvol", - "subst", - "pushd", - "popd", - // Network potentially mutating - "curl", - "wget", - "ftp", - "sftp", - "tftp", - "ftps", - // Windows potentially mutating (PowerShell) - non-destructive only - "set-item", - "set-itemproperty", - "set-location", - "set-variable", - "set-alias", - "set-executionpolicy", - "set-service", - "set-process", - "set-date", - "set-time", - "new-item", - "new-itemproperty", - "new-item -itemtype", - "new-item -path", - "register-scheduledtask", - "enable-scheduledtask", - "disable-scheduledtask", - "new-scheduledtask", - "new-module", - "import-module", - "import-pssession", - "new-pssession", - "enter-pssession", - "exit-pssession", - "new-runspace", - "enter-runspace", - "exit-runspace", - "new-job", - "wait-job", - "receive-job", - "new-appdomain", - // Dangerous Windows commands with wildcards - "del *", - "del *.*", - "erase *", - "erase *.*", - "rd /s", - "rmdir /s", - "move *", - "move *.*", - "copy *", - "copy *.*", - "xcopy *", - "xcopy *.*", - "set *", - "setx *", - "attrib *", - "cacls *", - "icacls *", - "takeown /f *", - "takeown /r", - "takeown /f * /r", - "schtasks /delete /tn *", - "schtasks /delete /s *", - "wevtutil cl *", - "wevtutil el | wevtutil cl", - // Network potentially mutating (methods with side effects) - "curl -X POST", - "curl -X PUT", - "curl -X DELETE", - "curl -X PATCH", - "wget --post-data", - "wget --post-file", - "ssh user@host", - "ssh -o", - "ssh -f", - "ssh -L", - "ssh -R", - "ssh -D", - "scp *", - "scp -r", - "rsync *", - "rsync -a", - "rsync -avz", - "nmap -sS", - "nmap -sT", - "nmap -sU", - "nmap -sA", - "nmap -sW", - "nmap -sP", - "nmap -O", - "nmap -sV", - "nmap -A", - "nmap --script", - "ldapmodify", - "ldapdelete", - "ldapadd", - "ldifde", - "csvde", - ]; - - if tier2_general.contains(&command) { return CommandTier::Tier2; } - // Default: unknown commands are Tier 2 (require approval) + // ── systemctl (Bug 2 fix: moved here — was dead code inside tier1_general) ── + if command == "systemctl" { + if let Some(sub) = subcommand { + if TIER1_SYSTEMCTL_SUBCOMMANDS.contains(&sub) { + return CommandTier::Tier1; + } + } + // restart, stop, start, enable, disable, reload, etc. → Tier 2 + return CommandTier::Tier2; + } + + // ── Proxmox ─────────────────────────────────────────────────────────── + if matches!(command, "pvecm" | "pvesh" | "qm") { + if let Some(sub) = subcommand { + if TIER1_PROXMOX_SUBCOMMANDS.contains(&sub) { + return CommandTier::Tier1; + } + if TIER2_PROXMOX_SUBCOMMANDS.contains(&sub) { + return CommandTier::Tier2; + } + } + return CommandTier::Tier2; + } + + // ── Tier 1 general ──────────────────────────────────────────────────── + // PowerShell get-* pattern catches any get- not explicitly listed + if command.starts_with("get-") { + return CommandTier::Tier1; + } + + if TIER1_GENERAL_COMMANDS.contains(&command) { + return CommandTier::Tier1; + } + + // ── Tier 2 general ──────────────────────────────────────────────────── + if TIER2_GENERAL_COMMANDS.contains(&command) { + return CommandTier::Tier2; + } + + // Default: unknown commands require approval CommandTier::Tier2 } fn parse_command_structure(command: &str) -> Vec { let mut components = Vec::new(); - - // Split by pipe, &&, ||, and ; - // This is a simple implementation - a full shell parser would be more complex let mut current_cmd = String::new(); let mut chars = command.chars().peekable(); while let Some(ch) = chars.next() { if ch == '|' { if chars.peek() == Some(&'|') { - // || chars.next(); if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } current_cmd.clear(); } else { - // | if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } current_cmd.clear(); } } else if ch == '&' && chars.peek() == Some(&'&') { - // && chars.next(); if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } current_cmd.clear(); } else if ch == ';' { - // ; if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } @@ -755,7 +636,6 @@ impl CommandClassifier { } } - // Add final component if !current_cmd.trim().is_empty() { components.push(Self::parse_single_component(current_cmd.trim())); } @@ -778,13 +658,11 @@ impl CommandClassifier { let mut subcommand = None; let mut args = Vec::new(); - // For kubectl, second part is the subcommand - if command == "kubectl" - || command == "pvecm" - || command == "pvesh" - || command == "qm" - || command == "systemctl" - { + // Commands for which the second token is the subcommand + if matches!( + command.as_str(), + "kubectl" | "pvecm" | "pvesh" | "qm" | "systemctl" + ) { if parts.len() > 1 { subcommand = Some(parts[1].to_string()); args = parts[2..].iter().map(|s| s.to_string()).collect(); @@ -877,7 +755,7 @@ mod tests { fn test_pipe_safe_to_danger() { let classifier = CommandClassifier::new(); let result = classifier.classify("kubectl get pods | kubectl delete -f -"); - assert_eq!(result.tier, CommandTier::Tier2); // Escalates to highest tier + assert_eq!(result.tier, CommandTier::Tier2); } #[test] @@ -904,7 +782,7 @@ mod tests { fn test_logical_and_operator() { let classifier = CommandClassifier::new(); let result = classifier.classify("ls /tmp && rm -rf /tmp/test"); - assert_eq!(result.tier, CommandTier::Tier3); // rm -rf is Tier 3 + assert_eq!(result.tier, CommandTier::Tier3); } #[test] @@ -918,7 +796,7 @@ mod tests { fn test_semicolon_separator() { let classifier = CommandClassifier::new(); let result = classifier.classify("cat file.txt; echo done"); - assert_eq!(result.tier, CommandTier::Tier1); // Both are safe + assert_eq!(result.tier, CommandTier::Tier1); } #[test] @@ -938,21 +816,18 @@ mod tests { #[test] fn test_general_safe_commands() { let classifier = CommandClassifier::new(); - let safe_commands = vec![ "cat /var/log/syslog", "grep error log.txt", "ls -la", "df -h", ]; - for cmd in safe_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier1, - "Command '{}' should be Tier 1", - cmd + "Command '{cmd}' should be Tier 1" ); } } @@ -960,16 +835,13 @@ mod tests { #[test] fn test_tier2_network_commands() { let classifier = CommandClassifier::new(); - let tier2_commands = vec!["ssh user@host", "scp file.txt user@host:"]; - for cmd in tier2_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier2, - "Command '{}' should be Tier 2", - cmd + "Command '{cmd}' should be Tier 2" ); } } @@ -977,18 +849,14 @@ mod tests { #[test] fn test_windows_tier1_readonly_commands() { let classifier = CommandClassifier::new(); - let tier1_commands = vec![ "dir", - "type file.txt", - "more < file.txt", "findstr pattern file.txt", "ipconfig", "ping 127.0.0.1", "tracert 127.0.0.1", "netstat", "whoami", - "date /t", "systeminfo", "ver", "hostname", @@ -999,21 +867,17 @@ mod tests { "get-content file.txt", "get-date", "get-location", - "get-physicalmemory", - "get-processor", "get-volume", "get-partition", "get-disk", "get-computerinfo", ]; - for cmd in tier1_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier1, - "Command '{}' should be Tier 1", - cmd + "Command '{cmd}' should be Tier 1" ); } } @@ -1021,7 +885,6 @@ mod tests { #[test] fn test_windows_tier2_mutating_commands() { let classifier = CommandClassifier::new(); - let tier2_commands = vec![ "move file.txt newfile.txt", "ren file.txt newfile.txt", @@ -1033,28 +896,20 @@ mod tests { "schtasks /create /tn test /tr test.exe", "reg add HKLM\\Software\\Test", "setx VAR value", - "move *", - "copy *.*", - "set *", - "setx *", - "attrib *", "new-item -path C:\\test", "set-itemproperty -path HKLM:\\Software\\Test -name Test -value 1", "sudo", "new-scheduledtask -action (new-scheduledtaskaction -execute notepad)", "register-scheduledtask -taskname test -action (new-scheduledtaskaction -execute notepad)", - "curl -X POST http://example.com", - "wget --post-data test http://example.com", - "time /t", - ]; - + "curl -X POST http://example.com", + "wget --post-data test http://example.com", + ]; for cmd in tier2_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier2, - "Command '{}' should be Tier 2", - cmd + "Command '{cmd}' should be Tier 2" ); } } @@ -1062,121 +917,178 @@ mod tests { #[test] fn test_windows_tier3_destructive_commands() { let classifier = CommandClassifier::new(); - let tier3_commands = vec![ "format C: /q", - "del *", - "del *.*", - "erase *", - "erase *.*", - "rd /s C:\\test", - "rmdir /s C:\\test", + "del file.txt", + "erase file.txt", "sdelete C:\\test", "bootrec /fixmbr", "bootrec /fixboot", "diskpart", - "remove-item -recurse -force C:\\test", + "remove-item -recurse C:\\test", "clear-recyclebin", "stop-computer", - "restart-computer -force", - "remove-wmiobject -query \"select * from win32_process where name='notepad.exe'\"", - "remove-itemproperty -path HKLM:\\Software\\Test -name Test", + "restart-computer", + "stop-process -name notepad", + "stop-service nginx", "uninstall-module -name PowerShellGet", "uninstall-package -name Package", "unregister-scheduledtask -taskname test", "dd if=/dev/zero of=/dev/sda", "mkfs.ext4 /dev/sda1", - "remove-item -recurse C:\\test", - "remove-item -force C:\\test", "clear-host", - "stop-process", - "stop-service", - "restart-computer", - "suspend-process", - "suspend-service", - "resume-process", - "resume-service", - "wait-process", - "wait-service", - "wait-computer", - "start-process", - "start-service", - "start-computer", - "invoke-item", - "unregister-scheduledtask", - "remove-scheduledtask", - "remove-job", - "remove-runspace", - "remove-appdomain", - "remove-pssession", - "remove-module", - "uninstall-package", - "uninstall-module", - "remove-wmiobject", - "remove-itemproperty", - "cipher /w:C:\\test", ]; - for cmd in tier3_commands { let result = classifier.classify(cmd); assert_eq!( result.tier, CommandTier::Tier3, - "Command '{}' should be Tier 3", - cmd + "Command '{cmd}' should be Tier 3" ); } } + // ── Bug fix tests ───────────────────────────────────────────────────────── + + #[test] + fn test_kill_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("kill -9 1234").tier, CommandTier::Tier3); + assert_eq!(c.classify("kill 1234").tier, CommandTier::Tier3); + } + #[test] - fn test_linux_windows_mixed_commands() { - let classifier = CommandClassifier::new(); + fn test_pkill_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("pkill -9 nginx").tier, CommandTier::Tier3); + assert_eq!(c.classify("pkill nginx").tier, CommandTier::Tier3); + } - // Linux commands - let linux_commands = vec![ - "cat /etc/passwd", - "ls -la /home", - "grep error /var/log/syslog", - "df -h", - "ps aux", - "systemctl status nginx", - "ssh user@host", - "scp file.txt user@host:", - "rm -rf /tmp/test", - "shutdown -h now", - ]; + #[test] + fn test_killall_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("killall nginx").tier, CommandTier::Tier3); + } - for cmd in linux_commands { - let result = classifier.classify(cmd); - assert!( - result.tier == CommandTier::Tier1 - || result.tier == CommandTier::Tier2 - || result.tier == CommandTier::Tier3, - "Linux command '{}' should have a tier", - cmd - ); - } + #[test] + fn test_init_is_tier3() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("init 0").tier, CommandTier::Tier3); + assert_eq!(c.classify("init 6").tier, CommandTier::Tier3); + } - // Windows commands - let windows_commands = vec![ - "dir C:\\", - "type C:\\test.txt", - "ipconfig /all", - "get-process", - "get-service", - "remove-item C:\\test", - "stop-process -name notepad", - ]; + #[test] + fn test_systemctl_status_is_tier1() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("systemctl status nginx").tier, + CommandTier::Tier1 + ); + assert_eq!( + c.classify("systemctl is-active nginx").tier, + CommandTier::Tier1 + ); + assert_eq!( + c.classify("systemctl is-enabled nginx").tier, + CommandTier::Tier1 + ); + assert_eq!(c.classify("systemctl list-units").tier, CommandTier::Tier1); + assert_eq!( + c.classify("systemctl list-unit-files").tier, + CommandTier::Tier1 + ); + } - for cmd in windows_commands { - let result = classifier.classify(cmd); - assert!( - result.tier == CommandTier::Tier1 - || result.tier == CommandTier::Tier2 - || result.tier == CommandTier::Tier3, - "Windows command '{}' should have a tier", - cmd - ); - } + #[test] + fn test_systemctl_mutating_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("systemctl restart nginx").tier, + CommandTier::Tier2 + ); + assert_eq!(c.classify("systemctl stop nginx").tier, CommandTier::Tier2); + assert_eq!(c.classify("systemctl start nginx").tier, CommandTier::Tier2); + assert_eq!( + c.classify("systemctl enable nginx").tier, + CommandTier::Tier2 + ); + assert_eq!( + c.classify("systemctl disable nginx").tier, + CommandTier::Tier2 + ); + } + + #[test] + fn test_ldapmodify_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("ldapmodify -f changes.ldif").tier, + CommandTier::Tier2 + ); + } + + #[test] + fn test_ldapdelete_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("ldapdelete cn=user,dc=example,dc=com").tier, + CommandTier::Tier2 + ); + } + + #[test] + fn test_ldapadd_is_tier2() { + let c = CommandClassifier::new(); + assert_eq!(c.classify("ldapadd -f new.ldif").tier, CommandTier::Tier2); + } + + #[test] + fn test_ldapsearch_is_tier1() { + let c = CommandClassifier::new(); + assert_eq!( + c.classify("ldapsearch -x -b dc=example,dc=com").tier, + CommandTier::Tier1 + ); + } + + #[test] + fn test_get_rules_returns_all_tiers() { + let rules = CommandClassifier::get_rules(); + assert!( + !rules.tier1_kubectl.is_empty(), + "tier1 kubectl should not be empty" + ); + assert!( + !rules.tier1_general.is_empty(), + "tier1 general should not be empty" + ); + assert!( + !rules.tier1_systemctl.is_empty(), + "tier1 systemctl should not be empty" + ); + assert!( + !rules.tier2_kubectl.is_empty(), + "tier2 kubectl should not be empty" + ); + assert!( + !rules.tier2_general.is_empty(), + "tier2 general should not be empty" + ); + assert!(!rules.tier3.is_empty(), "tier3 should not be empty"); + } + + #[test] + fn test_get_rules_tier1_contains_expected_kubectl() { + let rules = CommandClassifier::get_rules(); + assert!(rules.tier1_kubectl.iter().any(|s| s == "get")); + assert!(rules.tier1_kubectl.iter().any(|s| s == "logs")); + } + + #[test] + fn test_get_rules_tier3_contains_expected() { + let rules = CommandClassifier::get_rules(); + assert!(rules.tier3.iter().any(|s| s == "rm")); + assert!(rules.tier3.iter().any(|s| s == "kill")); + assert!(rules.tier3.iter().any(|s| s == "init")); } } diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 2f606989..4c13c276 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -739,6 +739,21 @@ export const listCommandExecutionsCmd = (issueId?: string) => export const checkKubectlInstalledCmd = () => invoke("check_kubectl_installed"); +export interface ClassifierRules { + tier1_kubectl: string[]; + tier1_systemctl: string[]; + tier1_proxmox: string[]; + tier1_general: string[]; + tier2_kubectl: string[]; + tier2_systemctl: string[]; + tier2_proxmox: string[]; + tier2_general: string[]; + tier3: string[]; +} + +export const getClassifierRulesCmd = () => + invoke("get_classifier_rules"); + // ─── Kubernetes Management Types ────────────────────────────────────────────── export interface ClusterInfo { diff --git a/src/pages/Settings/ShellExecution.tsx b/src/pages/Settings/ShellExecution.tsx index 50376a1f..998dc11b 100644 --- a/src/pages/Settings/ShellExecution.tsx +++ b/src/pages/Settings/ShellExecution.tsx @@ -1,17 +1,165 @@ import { useState, useEffect } from 'react'; -import { Terminal, CheckCircle, XCircle, Shield, History } from 'lucide-react'; +import { Terminal, CheckCircle, XCircle, Shield, History, ChevronDown } from 'lucide-react'; import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from '@/components/ui'; import { Link } from 'react-router-dom'; import { checkKubectlInstalledCmd, listCommandExecutionsCmd, + getClassifierRulesCmd, type KubectlStatus, type CommandExecution, + type ClassifierRules, } from '@/lib/tauriCommands'; +// ── Tier display config ─────────────────────────────────────────────────────── + +interface TierConfig { + label: string; + behavior: string; + colorBg: string; + colorBorder: string; + colorHeading: string; + colorText: string; + badgeClass: string; + tier: 1 | 2 | 3; +} + +const TIER_CONFIG: TierConfig[] = [ + { + tier: 1, + label: 'Tier 1', + behavior: 'Auto-execute (Read-only)', + colorBg: 'bg-green-50', + colorBorder: 'border-green-200', + colorHeading: 'text-green-900', + colorText: 'text-green-800', + badgeClass: 'bg-green-100 text-green-700 border-green-300', + }, + { + tier: 2, + label: 'Tier 2', + behavior: 'Require approval (Mutating)', + colorBg: 'bg-yellow-50', + colorBorder: 'border-yellow-200', + colorHeading: 'text-yellow-900', + colorText: 'text-yellow-800', + badgeClass: 'bg-yellow-100 text-yellow-700 border-yellow-300', + }, + { + tier: 3, + label: 'Tier 3', + behavior: 'Always deny (Destructive)', + colorBg: 'bg-red-50', + colorBorder: 'border-red-200', + colorHeading: 'text-red-900', + colorText: 'text-red-800', + badgeClass: 'bg-red-100 text-red-700 border-red-300', + }, +]; + +// ── Helper: build per-tier category groups from ClassifierRules ─────────────── + +interface CategoryGroup { + label: string; + commands: string[]; +} + +function buildTier1Groups(rules: ClassifierRules): CategoryGroup[] { + return [ + { label: 'kubectl', commands: rules.tier1_kubectl.map((c) => `kubectl ${c}`) }, + { label: 'systemctl', commands: rules.tier1_systemctl.map((c) => `systemctl ${c}`) }, + { label: 'proxmox', commands: rules.tier1_proxmox.map((c) => ` ${c}`) }, + { label: 'general', commands: rules.tier1_general }, + ].filter((g) => g.commands.length > 0); +} + +function buildTier2Groups(rules: ClassifierRules): CategoryGroup[] { + return [ + { label: 'kubectl', commands: rules.tier2_kubectl.map((c) => `kubectl ${c}`) }, + { label: 'systemctl', commands: rules.tier2_systemctl.map((c) => `systemctl ${c}`) }, + { label: 'proxmox', commands: rules.tier2_proxmox.map((c) => ` ${c}`) }, + { label: 'general', commands: rules.tier2_general }, + ].filter((g) => g.commands.length > 0); +} + +function buildTier3Groups(rules: ClassifierRules): CategoryGroup[] { + return [{ label: 'all', commands: rules.tier3 }]; +} + +const PREVIEW_COUNT = 6; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function CommandChip({ cmd, colorText }: { cmd: string; colorText: string }) { + return ( + + {cmd} + + ); +} + +interface TierCardProps { + config: TierConfig; + groups: CategoryGroup[]; +} + +function TierCard({ config, groups }: TierCardProps) { + const [expanded, setExpanded] = useState(false); + + const allCommands = groups.flatMap((g) => g.commands); + const total = allCommands.length; + const previewCommands = allCommands.slice(0, PREVIEW_COUNT); + const hasMore = total > PREVIEW_COUNT; + + return ( +
+
+
+ {config.label} +
+
{config.behavior}
+
+ {(expanded ? allCommands : previewCommands).map((cmd) => ( + + ))} +
+
+
+ + {total} + +
+ + {hasMore && ( + + )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + export default function ShellExecution() { const [kubectlStatus, setKubectlStatus] = useState(null); const [executions, setExecutions] = useState([]); + const [classifierRules, setClassifierRules] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -36,30 +184,44 @@ export default function ShellExecution() { } }; + const loadClassifierRules = async () => { + try { + const rules = await getClassifierRulesCmd(); + setClassifierRules(rules); + } catch { + // Non-fatal — fall back to empty state; the tier cards will just show 0 commands + } + }; + useEffect(() => { loadKubectlStatus(); loadExecutions(); + loadClassifierRules(); }, []); const getTierBadge = (tier: number) => { - const colors = { + const colors: Record = { 1: 'bg-green-100 text-green-700 border-green-300', 2: 'bg-yellow-100 text-yellow-700 border-yellow-300', 3: 'bg-red-100 text-red-700 border-red-300', }; - return colors[tier as keyof typeof colors] || colors[1]; + return colors[tier] ?? colors[1]; }; const getStatusBadge = (status: string) => { - const config = { + const config: Record = { auto: { label: 'Auto-executed', color: 'bg-blue-100 text-blue-700 border-blue-300' }, approved: { label: 'Approved', color: 'bg-green-100 text-green-700 border-green-300' }, denied: { label: 'Denied', color: 'bg-red-100 text-red-700 border-red-300' }, }; - const statusConfig = config[status as keyof typeof config] || config.auto; - return statusConfig; + return config[status] ?? config.auto; }; + // Build grouped command lists for each tier (empty arrays when rules not loaded) + const tier1Groups = classifierRules ? buildTier1Groups(classifierRules) : []; + const tier2Groups = classifierRules ? buildTier2Groups(classifierRules) : []; + const tier3Groups = classifierRules ? buildTier3Groups(classifierRules) : []; + return (
@@ -126,7 +288,7 @@ export default function ShellExecution() { - {/* Safety Architecture */} + {/* Safety Architecture — driven by live classifier data */} @@ -134,42 +296,21 @@ export default function ShellExecution() { Safety Architecture - +

- Commands are automatically classified into three safety tiers: + Commands are automatically classified into three safety tiers. The lists below + reflect the active classifier rules — they update whenever a rule is added or removed.

-
-
- Tier 1 -
-
Auto-execute (Read-only)
-
- kubectl get, describe, logs | cat, grep, ls -
-
-
+ {!classifierRules && ( +

Loading classifier rules…

+ )} -
- Tier 2 -
-
Require approval (Mutating)
-
- kubectl apply, delete, scale | ssh, chmod, systemctl restart -
-
-
- -
- Tier 3 -
-
Always deny (Destructive)
-
- rm -rf, shutdown, mkfs, dd -
-
-
-
+ {TIER_CONFIG.map((cfg) => { + const groups = + cfg.tier === 1 ? tier1Groups : cfg.tier === 2 ? tier2Groups : tier3Groups; + return ; + })}
@@ -201,12 +342,8 @@ export default function ShellExecution() {
- - T{exec.tier} - - - {statusConfig.label} - + T{exec.tier} + {statusConfig.label}
diff --git a/tests/unit/shellCommands.test.ts b/tests/unit/shellCommands.test.ts new file mode 100644 index 00000000..ee851c37 --- /dev/null +++ b/tests/unit/shellCommands.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import * as tauriCommands from "@/lib/tauriCommands"; + +vi.mock("@tauri-apps/api/core"); + +type MockedFunction unknown> = T & { + mockResolvedValue: (value: unknown) => void; + mockRejectedValue: (error: Error) => void; +}; + +const mockClassifierRules = { + tier1_kubectl: ["get", "describe", "logs", "explain", "api-resources", "api-versions", "cluster-info", "top", "version"], + tier1_systemctl: ["status", "is-active", "is-enabled", "list-units", "list-unit-files"], + tier1_proxmox: ["status", "get"], + tier1_general: ["cat", "grep", "ls", "find", "df", "free", "ps", "dig", "nslookup", "ldapsearch"], + tier2_kubectl: ["apply", "delete", "edit", "scale", "rollout", "exec", "cp", "port-forward"], + tier2_systemctl: ["restart", "stop", "start", "enable", "disable", "reload", "mask", "unmask"], + tier2_proxmox: ["migrate", "create", "set", "delete", "start", "stop"], + tier2_general: ["ssh", "scp", "chmod", "chown", "curl", "wget", "ldapmodify", "ldapdelete", "ldapadd"], + tier3: ["rm", "mkfs", "dd", "fdisk", "kill", "pkill", "killall", "init", "shutdown", "reboot", "halt", "poweroff"], +}; + +describe("Shell Classifier Commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getClassifierRulesCmd", () => { + it("should call invoke with correct command name", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + await tauriCommands.getClassifierRulesCmd(); + + expect(invoke).toHaveBeenCalledWith("get_classifier_rules"); + }); + + it("should return the classifier rules structure", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier1_kubectl).toContain("get"); + expect(result.tier1_kubectl).toContain("logs"); + expect(result.tier2_kubectl).toContain("apply"); + expect(result.tier2_kubectl).toContain("delete"); + expect(result.tier3).toContain("rm"); + expect(result.tier3).toContain("kill"); + expect(result.tier3).toContain("init"); + }); + + it("should include fix for Bug 1 — kill and init in tier3", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier3).toContain("kill"); + expect(result.tier3).toContain("pkill"); + expect(result.tier3).toContain("killall"); + expect(result.tier3).toContain("init"); + }); + + it("should include fix for Bug 2 — systemctl read-only subcommands in tier1", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier1_systemctl).toContain("status"); + expect(result.tier1_systemctl).toContain("is-active"); + expect(result.tier2_systemctl).toContain("restart"); + expect(result.tier2_systemctl).toContain("stop"); + }); + + it("should include fix for Bug 3 — ldap mutating ops in tier2 not tier1", async () => { + (invoke as MockedFunction).mockResolvedValue(mockClassifierRules); + + const result = await tauriCommands.getClassifierRulesCmd(); + + expect(result.tier2_general).toContain("ldapmodify"); + expect(result.tier2_general).toContain("ldapdelete"); + expect(result.tier2_general).toContain("ldapadd"); + // ldapsearch must NOT appear in tier2 (it's read-only, belongs in tier1) + expect(result.tier2_general).not.toContain("ldapsearch"); + expect(result.tier1_general).toContain("ldapsearch"); + }); + + it("should propagate errors from invoke", async () => { + (invoke as MockedFunction).mockRejectedValue(new Error("IPC error")); + + await expect(tauriCommands.getClassifierRulesCmd()).rejects.toThrow("IPC error"); + }); + }); +});