feat: add three-tier shell execution with kubectl support
Introduce shell classifier, executor, kubeconfig manager, and kubectl binary management. Integrates with existing commands/agentic.rs primitives. - Add shell/classifier.rs: Three-tier safety classification (Tier 1: auto-execute, Tier 2: approve, Tier 3: deny) - Add shell/executor.rs: Command executor with approval gates - Add shell/kubeconfig.rs: kubeconfig parsing and management - Add shell/kubectl.rs: kubectl binary management - Add commands/shell.rs: Tauri IPC commands for shell execution - Update state.rs: Add pending_approvals field for approval flow - Update lib.rs: Register shell module and commands Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6105f5af2b
commit
ea170ab340
@ -5,4 +5,5 @@ pub mod db;
|
|||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod integrations;
|
pub mod integrations;
|
||||||
|
pub mod shell;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
|
|||||||
234
src-tauri/src/commands/shell.rs
Normal file
234
src-tauri/src/commands/shell.rs
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// Shell Command Execution Tauri Commands
|
||||||
|
//
|
||||||
|
// This module provides Tauri commands for the frontend to:
|
||||||
|
// - Manage kubeconfig files (upload, list, activate, delete)
|
||||||
|
// - Respond to shell command approval requests
|
||||||
|
// - List command execution history
|
||||||
|
// - Check kubectl installation status
|
||||||
|
|
||||||
|
use crate::shell::KubeconfigInfo;
|
||||||
|
use crate::state::{AppState, ApprovalResponse};
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CommandExecution {
|
||||||
|
pub id: String,
|
||||||
|
pub command: String,
|
||||||
|
pub tier: i32,
|
||||||
|
pub approval_status: String,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
pub stdout: Option<String>,
|
||||||
|
pub stderr: Option<String>,
|
||||||
|
pub execution_time_ms: Option<i64>,
|
||||||
|
pub executed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KubectlStatus {
|
||||||
|
pub installed: bool,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_kubeconfig(
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Generate ID
|
||||||
|
let id = uuid::Uuid::now_v7().to_string();
|
||||||
|
|
||||||
|
// Parse kubeconfig to extract context
|
||||||
|
let contexts = crate::shell::kubeconfig::parse_kubeconfig_contexts(&content)?;
|
||||||
|
let context = contexts
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| "No contexts found in kubeconfig".to_string())?;
|
||||||
|
|
||||||
|
// Encrypt content
|
||||||
|
let encrypted_content = crate::integrations::auth::encrypt_token(&content)?;
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
{
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO kubeconfig_files (id, name, encrypted_content, context, cluster_url, is_active)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
||||||
|
params![&id, &name, &encrypted_content, &context.name, &context.cluster_url],
|
||||||
|
).map_err(|e| format!("Failed to store kubeconfig: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_kubeconfigs(state: State<'_, AppState>) -> Result<Vec<KubeconfigInfo>, String> {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut stmt = db
|
||||||
|
.prepare("SELECT id, name, context, cluster_url, is_active FROM kubeconfig_files ORDER BY uploaded_at DESC")
|
||||||
|
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
||||||
|
|
||||||
|
let configs = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(KubeconfigInfo {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
context: row.get(2)?,
|
||||||
|
cluster_url: row.get(3)?,
|
||||||
|
is_active: row.get::<_, i32>(4)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to query kubeconfigs: {e}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("Failed to collect results: {e}"))?;
|
||||||
|
|
||||||
|
Ok(configs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn activate_kubeconfig(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Deactivate all configs
|
||||||
|
db.execute("UPDATE kubeconfig_files SET is_active = 0", [])
|
||||||
|
.map_err(|e| format!("Failed to deactivate configs: {e}"))?;
|
||||||
|
|
||||||
|
// Activate the specified config
|
||||||
|
db.execute(
|
||||||
|
"UPDATE kubeconfig_files SET is_active = 1 WHERE id = ?1",
|
||||||
|
params![&id],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to activate config: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_kubeconfig(id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
db.execute("DELETE FROM kubeconfig_files WHERE id = ?1", params![&id])
|
||||||
|
.map_err(|e| format!("Failed to delete kubeconfig: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn respond_to_shell_approval(
|
||||||
|
approval_id: String,
|
||||||
|
decision: String, // "deny", "allow_once", "allow_session"
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Retrieve the pending approval channel
|
||||||
|
let sender = {
|
||||||
|
let mut approvals = state.pending_approvals.lock().await;
|
||||||
|
approvals.remove(&approval_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(sender) = sender {
|
||||||
|
let approved = decision != "deny";
|
||||||
|
let response = ApprovalResponse { approved, decision };
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
sender
|
||||||
|
.send(response)
|
||||||
|
.map_err(|_| "Failed to send approval response".to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Approval request not found or already responded to".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_command_executions(
|
||||||
|
issue_id: Option<String>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<CommandExecution>, String> {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let (query, params_vec): (String, Vec<String>) = if let Some(issue_id) = issue_id {
|
||||||
|
(
|
||||||
|
"SELECT id, command, tier, approval_status, exit_code, stdout, stderr, execution_time_ms, executed_at
|
||||||
|
FROM command_executions
|
||||||
|
WHERE issue_id = ?1
|
||||||
|
ORDER BY executed_at DESC
|
||||||
|
LIMIT 100".to_string(),
|
||||||
|
vec![issue_id],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"SELECT id, command, tier, approval_status, exit_code, stdout, stderr, execution_time_ms, executed_at
|
||||||
|
FROM command_executions
|
||||||
|
ORDER BY executed_at DESC
|
||||||
|
LIMIT 100".to_string(),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = db
|
||||||
|
.prepare(&query)
|
||||||
|
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
||||||
|
|
||||||
|
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec
|
||||||
|
.iter()
|
||||||
|
.map(|s| s as &dyn rusqlite::ToSql)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let executions = stmt
|
||||||
|
.query_map(params_refs.as_slice(), |row| {
|
||||||
|
Ok(CommandExecution {
|
||||||
|
id: row.get(0)?,
|
||||||
|
command: row.get(1)?,
|
||||||
|
tier: row.get(2)?,
|
||||||
|
approval_status: row.get(3)?,
|
||||||
|
exit_code: row.get(4)?,
|
||||||
|
stdout: row.get(5)?,
|
||||||
|
stderr: row.get(6)?,
|
||||||
|
execution_time_ms: row.get(7)?,
|
||||||
|
executed_at: row.get(8)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to query executions: {e}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| format!("Failed to collect results: {e}"))?;
|
||||||
|
|
||||||
|
Ok(executions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_kubectl_installed(_state: State<'_, AppState>) -> Result<KubectlStatus, String> {
|
||||||
|
match crate::shell::kubectl::locate_kubectl() {
|
||||||
|
Ok(path) => {
|
||||||
|
// Try to get version
|
||||||
|
let version = tokio::process::Command::new(&path)
|
||||||
|
.arg("version")
|
||||||
|
.arg("--client")
|
||||||
|
.arg("--output=json")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|output| {
|
||||||
|
if output.status.success() {
|
||||||
|
String::from_utf8(output.stdout).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(KubectlStatus {
|
||||||
|
installed: true,
|
||||||
|
path: Some(path.to_string_lossy().to_string()),
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => Ok(KubectlStatus {
|
||||||
|
installed: false,
|
||||||
|
path: None,
|
||||||
|
version: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ pub mod integrations;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod pii;
|
pub mod pii;
|
||||||
|
pub mod shell;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@ -38,6 +39,7 @@ pub fn run() {
|
|||||||
app_data_dir: data_dir.clone(),
|
app_data_dir: data_dir.clone(),
|
||||||
integration_webviews: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
integration_webviews: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||||
mcp_connections: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
mcp_connections: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
|
pending_approvals: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
};
|
};
|
||||||
let stronghold_salt = format!(
|
let stronghold_salt = format!(
|
||||||
"tftsr-stronghold-salt-v1-{:x}",
|
"tftsr-stronghold-salt-v1-{:x}",
|
||||||
@ -151,6 +153,14 @@ pub fn run() {
|
|||||||
mcp::commands::discover_mcp_server,
|
mcp::commands::discover_mcp_server,
|
||||||
mcp::commands::get_mcp_server_status,
|
mcp::commands::get_mcp_server_status,
|
||||||
mcp::commands::initiate_mcp_oauth,
|
mcp::commands::initiate_mcp_oauth,
|
||||||
|
// Shell Execution
|
||||||
|
commands::shell::upload_kubeconfig,
|
||||||
|
commands::shell::list_kubeconfigs,
|
||||||
|
commands::shell::activate_kubeconfig,
|
||||||
|
commands::shell::delete_kubeconfig,
|
||||||
|
commands::shell::respond_to_shell_approval,
|
||||||
|
commands::shell::list_command_executions,
|
||||||
|
commands::shell::check_kubectl_installed,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error running Troubleshooting and RCA Assistant application");
|
.expect("Error running Troubleshooting and RCA Assistant application");
|
||||||
|
|||||||
517
src-tauri/src/shell/classifier.rs
Normal file
517
src-tauri/src/shell/classifier.rs
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
// Command Safety Classifier - TDD Implementation
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
|
pub enum CommandTier {
|
||||||
|
Tier1, // Auto-execute
|
||||||
|
Tier2, // Requires approval
|
||||||
|
Tier3, // Always deny
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandTier {
|
||||||
|
pub fn to_tier_number(&self) -> i32 {
|
||||||
|
match self {
|
||||||
|
CommandTier::Tier1 => 1,
|
||||||
|
CommandTier::Tier2 => 2,
|
||||||
|
CommandTier::Tier3 => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandComponent {
|
||||||
|
pub command: String,
|
||||||
|
pub subcommand: Option<String>,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ClassificationResult {
|
||||||
|
pub tier: CommandTier,
|
||||||
|
pub components: Vec<CommandComponent>,
|
||||||
|
pub reasoning: String,
|
||||||
|
pub risk_factors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommandClassifier;
|
||||||
|
|
||||||
|
impl Default for CommandClassifier {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandClassifier {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
CommandClassifier
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(&self, command: &str) -> ClassificationResult {
|
||||||
|
let mut risk_factors = Vec::new();
|
||||||
|
|
||||||
|
// Check for command substitution
|
||||||
|
if command.contains("$(") || command.contains("`") {
|
||||||
|
risk_factors.push("command_substitution".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command into components (handle pipes, &&, ||, ;)
|
||||||
|
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();
|
||||||
|
|
||||||
|
for component in &components {
|
||||||
|
let tier =
|
||||||
|
self.classify_single_command(&component.command, component.subcommand.as_deref());
|
||||||
|
|
||||||
|
match tier {
|
||||||
|
CommandTier::Tier3 => {
|
||||||
|
highest_tier = CommandTier::Tier3;
|
||||||
|
reasoning_parts.push(format!(
|
||||||
|
"'{}' is a destructive operation",
|
||||||
|
component.command
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CommandTier::Tier2 => {
|
||||||
|
if highest_tier != CommandTier::Tier3 {
|
||||||
|
highest_tier = CommandTier::Tier2;
|
||||||
|
reasoning_parts
|
||||||
|
.push(format!("'{}' is a mutating operation", component.command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandTier::Tier1 => {
|
||||||
|
if reasoning_parts.is_empty() && highest_tier == CommandTier::Tier1 {
|
||||||
|
reasoning_parts.push("read-only operations only".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command substitution escalates to Tier 2
|
||||||
|
if !risk_factors.is_empty() && highest_tier == CommandTier::Tier1 {
|
||||||
|
highest_tier = CommandTier::Tier2;
|
||||||
|
reasoning_parts.push("contains command substitution".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let reasoning = if reasoning_parts.is_empty() {
|
||||||
|
"safe read-only command".to_string()
|
||||||
|
} else {
|
||||||
|
reasoning_parts.join(", ")
|
||||||
|
};
|
||||||
|
|
||||||
|
ClassificationResult {
|
||||||
|
tier: highest_tier,
|
||||||
|
components,
|
||||||
|
reasoning,
|
||||||
|
risk_factors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_single_command(&self, command: &str, subcommand: Option<&str>) -> CommandTier {
|
||||||
|
// Tier 3: Always deny - destructive operations
|
||||||
|
let tier3_commands = [
|
||||||
|
"rm", "mkfs", "dd", "fdisk", "parted", "shutdown", "reboot", "halt", "poweroff",
|
||||||
|
];
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return CommandTier::Tier3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: kubectl read-only subcommands
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
return CommandTier::Tier2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default kubectl to Tier 2 if subcommand unknown
|
||||||
|
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
|
||||||
|
let tier1_general = [
|
||||||
|
"cat",
|
||||||
|
"grep",
|
||||||
|
"ls",
|
||||||
|
"find",
|
||||||
|
"df",
|
||||||
|
"free",
|
||||||
|
"ps",
|
||||||
|
"ss",
|
||||||
|
"netstat",
|
||||||
|
"journalctl",
|
||||||
|
"systemctl",
|
||||||
|
"echo",
|
||||||
|
"pwd",
|
||||||
|
"whoami",
|
||||||
|
"date",
|
||||||
|
"uptime",
|
||||||
|
"head",
|
||||||
|
"tail",
|
||||||
|
"less",
|
||||||
|
"more",
|
||||||
|
"wc",
|
||||||
|
"sort",
|
||||||
|
"uniq",
|
||||||
|
"cut",
|
||||||
|
"tr",
|
||||||
|
"test",
|
||||||
|
];
|
||||||
|
|
||||||
|
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" {
|
||||||
|
return CommandTier::Tier1;
|
||||||
|
}
|
||||||
|
// restart, reload, etc. are Tier 2
|
||||||
|
return CommandTier::Tier2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CommandTier::Tier1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: Network and potentially mutating commands
|
||||||
|
let tier2_general = [
|
||||||
|
"ssh", "scp", "rsync", "curl", "wget", "chmod", "chown", "mv", "cp", "awk",
|
||||||
|
"sed", // Can be safe, but can also modify
|
||||||
|
];
|
||||||
|
|
||||||
|
if tier2_general.contains(&command) {
|
||||||
|
return CommandTier::Tier2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: unknown commands are Tier 2 (require approval)
|
||||||
|
CommandTier::Tier2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command_structure(command: &str) -> Vec<CommandComponent> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
current_cmd.clear();
|
||||||
|
} else {
|
||||||
|
current_cmd.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final component
|
||||||
|
if !current_cmd.trim().is_empty() {
|
||||||
|
components.push(Self::parse_single_component(current_cmd.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_single_component(cmd_str: &str) -> CommandComponent {
|
||||||
|
let parts: Vec<&str> = cmd_str.split_whitespace().collect();
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
return CommandComponent {
|
||||||
|
command: String::new(),
|
||||||
|
subcommand: None,
|
||||||
|
args: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = parts[0].to_string();
|
||||||
|
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"
|
||||||
|
{
|
||||||
|
if parts.len() > 1 {
|
||||||
|
subcommand = Some(parts[1].to_string());
|
||||||
|
args = parts[2..].iter().map(|s| s.to_string()).collect();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = parts[1..].iter().map(|s| s.to_string()).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandComponent {
|
||||||
|
command,
|
||||||
|
subcommand,
|
||||||
|
args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier1_kubectl_get() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl get pods");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier1);
|
||||||
|
assert_eq!(result.components.len(), 1);
|
||||||
|
assert!(result.reasoning.contains("read-only") || result.reasoning.contains("safe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier1_kubectl_describe() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl describe pod nginx");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier1_kubectl_logs() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl logs nginx-pod");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier2_kubectl_delete() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl delete pod nginx");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier2);
|
||||||
|
assert!(result.reasoning.contains("delete") || result.reasoning.contains("mutating"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier2_kubectl_apply() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl apply -f deployment.yaml");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier2_kubectl_scale() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl scale deployment nginx --replicas=5");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier3_rm_rf() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("rm -rf /");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier3);
|
||||||
|
assert!(result.reasoning.contains("destructive") || result.reasoning.contains("dangerous"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tier3_shutdown() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("shutdown -h now");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_safe_to_safe() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl get pods | grep nginx");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier1);
|
||||||
|
assert_eq!(result.components.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_substitution() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl get $(dangerous)");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier2);
|
||||||
|
assert!(result
|
||||||
|
.risk_factors
|
||||||
|
.contains(&"command_substitution".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backtick_substitution() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("kubectl get `whoami`");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier2);
|
||||||
|
assert!(result
|
||||||
|
.risk_factors
|
||||||
|
.contains(&"command_substitution".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_logical_or_operator() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("test -f file || rm -rf /tmp");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_proxmox_tier1() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("pvecm status");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_proxmox_tier2() {
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let result = classifier.classify("qm migrate 100 node2");
|
||||||
|
assert_eq!(result.tier, CommandTier::Tier2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
332
src-tauri/src/shell/executor.rs
Normal file
332
src-tauri/src/shell/executor.rs
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
// Command Executor with Approval Flow
|
||||||
|
//
|
||||||
|
// This module handles:
|
||||||
|
// - Command execution with safety tier enforcement
|
||||||
|
// - User approval flow for Tier 2 commands
|
||||||
|
// - PII detection and audit logging
|
||||||
|
// - Timeout protection
|
||||||
|
|
||||||
|
use crate::shell::classifier::{CommandClassifier, CommandTier};
|
||||||
|
use crate::state::{AppState, ApprovalResponse};
|
||||||
|
use rusqlite::params;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
pub use crate::shell::kubectl::CommandOutput;
|
||||||
|
|
||||||
|
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub async fn execute_with_approval(
|
||||||
|
command: &str,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
state: &AppState,
|
||||||
|
kubeconfig_id: Option<&str>,
|
||||||
|
working_dir: Option<&str>,
|
||||||
|
) -> Result<CommandOutput, String> {
|
||||||
|
// Step 1: Classify command
|
||||||
|
let classifier = CommandClassifier::new();
|
||||||
|
let classification = classifier.classify(command);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
command = %command,
|
||||||
|
tier = ?classification.tier,
|
||||||
|
reasoning = %classification.reasoning,
|
||||||
|
"Command classified"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Match on tier
|
||||||
|
match classification.tier {
|
||||||
|
CommandTier::Tier3 => {
|
||||||
|
// Always deny
|
||||||
|
tracing::warn!(
|
||||||
|
command = %command,
|
||||||
|
reasoning = %classification.reasoning,
|
||||||
|
"Command denied (Tier 3)"
|
||||||
|
);
|
||||||
|
return Err(format!(
|
||||||
|
"Command denied: {} (Tier 3: {})",
|
||||||
|
command, classification.reasoning
|
||||||
|
));
|
||||||
|
}
|
||||||
|
CommandTier::Tier2 => {
|
||||||
|
// Require approval
|
||||||
|
let approved = request_approval(command, &classification, app_handle, state).await?;
|
||||||
|
|
||||||
|
if !approved {
|
||||||
|
tracing::warn!(command = %command, "Command denied by user");
|
||||||
|
return Err(format!("Command denied by user: {command}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandTier::Tier1 => {
|
||||||
|
// Auto-execute (no approval needed)
|
||||||
|
tracing::info!(command = %command, "Auto-executing Tier 1 command");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Execute command (Tier 1 or approved Tier 2)
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let output = execute_command(command, kubeconfig_id, working_dir, state).await?;
|
||||||
|
let execution_time_ms = start_time.elapsed().as_millis() as i64;
|
||||||
|
|
||||||
|
// Step 4: Record execution in database
|
||||||
|
let approval_status = match classification.tier {
|
||||||
|
CommandTier::Tier1 => "auto",
|
||||||
|
CommandTier::Tier2 => "approved",
|
||||||
|
CommandTier::Tier3 => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
record_execution(
|
||||||
|
command,
|
||||||
|
classification.tier.to_tier_number(),
|
||||||
|
approval_status,
|
||||||
|
kubeconfig_id,
|
||||||
|
&output,
|
||||||
|
execution_time_ms,
|
||||||
|
state,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Step 5: Audit log
|
||||||
|
write_audit_log(command, &output, state)?;
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_approval(
|
||||||
|
command: &str,
|
||||||
|
classification: &crate::shell::classifier::ClassificationResult,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// Generate approval ID
|
||||||
|
let approval_id = uuid::Uuid::now_v7().to_string();
|
||||||
|
|
||||||
|
// Create oneshot channel
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel::<ApprovalResponse>();
|
||||||
|
|
||||||
|
// Store channel
|
||||||
|
{
|
||||||
|
let mut approvals = state.pending_approvals.lock().await;
|
||||||
|
approvals.insert(approval_id.clone(), sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit approval event to frontend
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct ApprovalRequest {
|
||||||
|
approval_id: String,
|
||||||
|
command: String,
|
||||||
|
tier: i32,
|
||||||
|
reasoning: String,
|
||||||
|
risk_factors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = ApprovalRequest {
|
||||||
|
approval_id: approval_id.clone(),
|
||||||
|
command: command.to_string(),
|
||||||
|
tier: classification.tier.to_tier_number(),
|
||||||
|
reasoning: classification.reasoning.clone(),
|
||||||
|
risk_factors: classification.risk_factors.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
app_handle
|
||||||
|
.emit("shell:approval-needed", request)
|
||||||
|
.map_err(|e| format!("Failed to emit approval event: {e}"))?;
|
||||||
|
|
||||||
|
// Wait for response with timeout
|
||||||
|
match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await {
|
||||||
|
Ok(Ok(response)) => Ok(response.approved),
|
||||||
|
Ok(Err(_)) => Err("Approval channel closed".to_string()),
|
||||||
|
Err(_) => {
|
||||||
|
// Timeout - clean up
|
||||||
|
let mut approvals = state.pending_approvals.lock().await;
|
||||||
|
approvals.remove(&approval_id);
|
||||||
|
Err("Approval request timed out".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_command(
|
||||||
|
command: &str,
|
||||||
|
kubeconfig_id: Option<&str>,
|
||||||
|
working_dir: Option<&str>,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<CommandOutput, String> {
|
||||||
|
// Check if kubectl command
|
||||||
|
if command.trim().starts_with("kubectl") {
|
||||||
|
// Extract kubectl args
|
||||||
|
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||||
|
let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
// Get kubeconfig path - use provided ID or fallback to active kubeconfig
|
||||||
|
let kubeconfig_path = if let Some(id) = kubeconfig_id {
|
||||||
|
Some(get_kubeconfig_path(id, state)?)
|
||||||
|
} else {
|
||||||
|
// Auto-select active kubeconfig for kubectl commands
|
||||||
|
get_active_kubeconfig_path(state).ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
return crate::shell::kubectl::execute_kubectl(
|
||||||
|
&args,
|
||||||
|
kubeconfig_path.as_deref(),
|
||||||
|
working_dir,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// General shell command execution
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let mut cmd = {
|
||||||
|
let mut c = tokio::process::Command::new("cmd");
|
||||||
|
c.arg("/C").arg(command);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let mut cmd = {
|
||||||
|
let mut c = tokio::process::Command::new("sh");
|
||||||
|
c.arg("-c").arg(command);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(dir) = working_dir {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute with timeout
|
||||||
|
let start = Instant::now();
|
||||||
|
let output = tokio::time::timeout(COMMAND_TIMEOUT, cmd.output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Command execution timed out".to_string())?
|
||||||
|
.map_err(|e| format!("Failed to execute command: {e}"))?;
|
||||||
|
let execution_time_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
Ok(CommandOutput {
|
||||||
|
exit_code: output.status.code().unwrap_or(-1),
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
execution_time_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_kubeconfig_path(kubeconfig_id: &str, state: &AppState) -> Result<String, String> {
|
||||||
|
// Retrieve encrypted kubeconfig from database
|
||||||
|
let encrypted_content = {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
db.query_row(
|
||||||
|
"SELECT encrypted_content FROM kubeconfig_files WHERE id = ?1",
|
||||||
|
params![kubeconfig_id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Kubeconfig not found: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt kubeconfig content
|
||||||
|
let decrypted_content = crate::integrations::auth::decrypt_token(&encrypted_content)?;
|
||||||
|
|
||||||
|
// Write to secure temp file
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let temp_path = temp_dir.join(format!("kubeconfig-{kubeconfig_id}.yaml"));
|
||||||
|
|
||||||
|
std::fs::write(&temp_path, decrypted_content)
|
||||||
|
.map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?;
|
||||||
|
|
||||||
|
Ok(temp_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_active_kubeconfig_path(state: &AppState) -> Result<String, String> {
|
||||||
|
// Get ID of active kubeconfig
|
||||||
|
let active_id = {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
db.query_row(
|
||||||
|
"SELECT id FROM kubeconfig_files WHERE is_active = 1 LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("No active kubeconfig found: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use existing get_kubeconfig_path function
|
||||||
|
get_kubeconfig_path(&active_id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_execution(
|
||||||
|
command: &str,
|
||||||
|
tier: i32,
|
||||||
|
approval_status: &str,
|
||||||
|
kubeconfig_id: Option<&str>,
|
||||||
|
output: &CommandOutput,
|
||||||
|
execution_time_ms: i64,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let id = uuid::Uuid::now_v7().to_string();
|
||||||
|
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO command_executions (id, command, tier, approval_status, kubeconfig_id, exit_code, stdout, stderr, execution_time_ms)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
&id,
|
||||||
|
command,
|
||||||
|
tier,
|
||||||
|
approval_status,
|
||||||
|
kubeconfig_id,
|
||||||
|
output.exit_code,
|
||||||
|
&output.stdout,
|
||||||
|
&output.stderr,
|
||||||
|
execution_time_ms,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to record execution: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_audit_log(command: &str, output: &CommandOutput, state: &AppState) -> Result<(), String> {
|
||||||
|
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"command": command,
|
||||||
|
"exit_code": output.exit_code,
|
||||||
|
});
|
||||||
|
|
||||||
|
crate::audit::log::write_audit_event(
|
||||||
|
&db,
|
||||||
|
"shell_command_execution",
|
||||||
|
"shell_command",
|
||||||
|
command,
|
||||||
|
&details.to_string(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Audit log failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
// Note: These tests will require mock AppState setup
|
||||||
|
// For now, they're placeholders
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Requires full app setup
|
||||||
|
async fn test_tier1_immediate_execution() {
|
||||||
|
// TODO: Test that Tier 1 commands execute immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Requires event system
|
||||||
|
async fn test_tier2_emits_approval_event() {
|
||||||
|
// TODO: Test that Tier 2 commands emit approval event
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Requires full app setup
|
||||||
|
async fn test_tier3_immediate_denial() {
|
||||||
|
// TODO: Test that Tier 3 commands are denied immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Requires timeout setup
|
||||||
|
async fn test_approval_timeout() {
|
||||||
|
// TODO: Test that approval requests timeout after 60s
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src-tauri/src/shell/kubeconfig.rs
Normal file
179
src-tauri/src/shell/kubeconfig.rs
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
// Kubeconfig Management
|
||||||
|
//
|
||||||
|
// This module handles:
|
||||||
|
// - Auto-detection of ~/.kube/config
|
||||||
|
// - Parsing kubeconfig YAML
|
||||||
|
// - Encrypted storage of kubeconfig files
|
||||||
|
// - Context switching
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KubeconfigContext {
|
||||||
|
pub name: String,
|
||||||
|
pub cluster_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KubeconfigInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub context: String,
|
||||||
|
pub cluster_url: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auto_detect_kubeconfig(_state: &AppState) -> Result<(), String> {
|
||||||
|
// TODO: Implement kubeconfig auto-detection
|
||||||
|
// For now, return an error instead of panicking
|
||||||
|
Err("Kubeconfig auto-detection not yet implemented".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_kubeconfig_contexts(content: &str) -> Result<Vec<KubeconfigContext>, String> {
|
||||||
|
// Parse YAML kubeconfig file
|
||||||
|
// Simple string parsing to extract contexts and cluster URLs
|
||||||
|
|
||||||
|
let mut contexts = Vec::new();
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
// First pass: find all contexts with their cluster names
|
||||||
|
let mut in_contexts = false;
|
||||||
|
let mut _current_context_name = String::new();
|
||||||
|
let mut current_cluster_name = String::new();
|
||||||
|
|
||||||
|
for line in &lines {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
if trimmed == "contexts:" {
|
||||||
|
in_contexts = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_contexts {
|
||||||
|
// Check if we've left the contexts section (hit another top-level key)
|
||||||
|
if !line.starts_with(' ') && !trimmed.is_empty() && !trimmed.starts_with('-') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context name (at the end of a context block)
|
||||||
|
if trimmed.starts_with("name:") && !current_cluster_name.is_empty() {
|
||||||
|
_current_context_name = trimmed.trim_start_matches("name:").trim().to_string();
|
||||||
|
|
||||||
|
// Find cluster URL
|
||||||
|
let cluster_url = find_cluster_url(&lines, ¤t_cluster_name);
|
||||||
|
|
||||||
|
contexts.push(KubeconfigContext {
|
||||||
|
name: _current_context_name.clone(),
|
||||||
|
cluster_url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset for next context
|
||||||
|
_current_context_name.clear();
|
||||||
|
current_cluster_name.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster reference (inside context block)
|
||||||
|
if trimmed.starts_with("cluster:") {
|
||||||
|
current_cluster_name = trimmed.trim_start_matches("cluster:").trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(contexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_cluster_url(lines: &[&str], cluster_name: &str) -> String {
|
||||||
|
let mut in_clusters = false;
|
||||||
|
let mut _current_cluster_name = String::new();
|
||||||
|
let mut found_target_cluster = false;
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
if trimmed == "clusters:" {
|
||||||
|
in_clusters = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_clusters {
|
||||||
|
// Check if we've left the clusters section
|
||||||
|
if !line.starts_with(' ') && !trimmed.is_empty() && !trimmed.starts_with('-') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found the name of a cluster
|
||||||
|
if trimmed.starts_with("name:") {
|
||||||
|
_current_cluster_name = trimmed.trim_start_matches("name:").trim().to_string();
|
||||||
|
|
||||||
|
if _current_cluster_name == cluster_name {
|
||||||
|
found_target_cluster = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found server URL - check if it's for our target cluster
|
||||||
|
if found_target_cluster && trimmed.starts_with("server:") {
|
||||||
|
return trimmed.trim_start_matches("server:").trim().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// New cluster definition starts - reset
|
||||||
|
if trimmed.starts_with("- cluster:") {
|
||||||
|
found_target_cluster = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_active_kubeconfig(_state: &AppState) -> Result<Option<String>, String> {
|
||||||
|
// TODO: Implement active kubeconfig retrieval
|
||||||
|
// For now, return an error instead of panicking
|
||||||
|
Err("Active kubeconfig retrieval not yet implemented".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_kubeconfig_contexts() {
|
||||||
|
let yaml = r#"
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Config
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
name: default
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: default
|
||||||
|
user: default
|
||||||
|
name: default
|
||||||
|
current-context: default
|
||||||
|
users:
|
||||||
|
- name: default
|
||||||
|
user:
|
||||||
|
token: test-token
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = parse_kubeconfig_contexts(yaml);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let contexts = result.unwrap();
|
||||||
|
assert_eq!(contexts.len(), 1);
|
||||||
|
assert_eq!(contexts[0].name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Requires AppState setup
|
||||||
|
fn test_encrypt_kubeconfig_content() {
|
||||||
|
// TODO: Test kubeconfig encryption using existing auth::encrypt_token
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore] // Requires database
|
||||||
|
async fn test_get_active_kubeconfig() {
|
||||||
|
// TODO: Test active kubeconfig retrieval
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src-tauri/src/shell/kubectl.rs
Normal file
198
src-tauri/src/shell/kubectl.rs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
// kubectl Binary Management
|
||||||
|
//
|
||||||
|
// This module handles:
|
||||||
|
// - Locating kubectl binary (bundled or system PATH)
|
||||||
|
// - Executing kubectl commands with proper environment isolation
|
||||||
|
// - Timeout protection
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommandOutput {
|
||||||
|
pub exit_code: i32,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
pub execution_time_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locate_kubectl() -> Result<PathBuf, String> {
|
||||||
|
// Strategy:
|
||||||
|
// 1. Check for bundled sidecar binary (platform-specific)
|
||||||
|
// 2. Fallback to system PATH (which kubectl)
|
||||||
|
// 3. Check common installation paths
|
||||||
|
|
||||||
|
// Check for bundled binary first
|
||||||
|
// In production builds, kubectl will be bundled as an external binary
|
||||||
|
let exe_suffix = if cfg!(windows) { ".exe" } else { "" };
|
||||||
|
|
||||||
|
// Try current directory (dev mode)
|
||||||
|
let local_kubectl = PathBuf::from(format!("kubectl{exe_suffix}"));
|
||||||
|
if local_kubectl.exists() {
|
||||||
|
return Ok(local_kubectl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Tauri sidecar binary (production builds)
|
||||||
|
// Tauri names sidecars with target triple suffix
|
||||||
|
if let Ok(exe_path) = std::env::current_exe() {
|
||||||
|
if let Some(exe_dir) = exe_path.parent() {
|
||||||
|
// Build target-triple-suffixed name
|
||||||
|
let target = std::env::consts::ARCH.to_string()
|
||||||
|
+ "-"
|
||||||
|
+ if cfg!(target_os = "linux") {
|
||||||
|
"unknown-linux-gnu"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"apple-darwin"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"pc-windows-msvc"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidecar_name = format!("kubectl-{target}{exe_suffix}");
|
||||||
|
let sidecar_path = exe_dir.join(&sidecar_name);
|
||||||
|
|
||||||
|
if sidecar_path.exists() {
|
||||||
|
return Ok(sidecar_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check Resources subdirectory (macOS .app bundle)
|
||||||
|
let resources_path = exe_dir.join("Resources").join(&sidecar_name);
|
||||||
|
if resources_path.exists() {
|
||||||
|
return Ok(resources_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check system PATH using 'which' on Unix or 'where' on Windows
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
if let Ok(output) = Command::new("which").arg("kubectl").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Ok(output) = Command::new("where").arg("kubectl").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common installation paths
|
||||||
|
let common_paths = [
|
||||||
|
"/usr/local/bin/kubectl",
|
||||||
|
"/usr/bin/kubectl",
|
||||||
|
"/opt/homebrew/bin/kubectl",
|
||||||
|
"/snap/bin/kubectl",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path_str in &common_paths {
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("kubectl binary not found. Please install kubectl or it will be bundled in production builds.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_kubectl(
|
||||||
|
args: &[String],
|
||||||
|
kubeconfig_path: Option<&str>,
|
||||||
|
working_dir: Option<&str>,
|
||||||
|
) -> Result<CommandOutput, String> {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Locate kubectl binary
|
||||||
|
let kubectl_path = locate_kubectl()?;
|
||||||
|
|
||||||
|
// Build command
|
||||||
|
let mut cmd = Command::new(&kubectl_path);
|
||||||
|
cmd.args(args);
|
||||||
|
|
||||||
|
// Set KUBECONFIG if provided
|
||||||
|
if let Some(kubeconfig) = kubeconfig_path {
|
||||||
|
cmd.env("KUBECONFIG", kubeconfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set working directory (default to system temp for safety)
|
||||||
|
if let Some(dir) = working_dir {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
} else {
|
||||||
|
cmd.current_dir(std::env::temp_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear potentially sensitive environment variables
|
||||||
|
cmd.env_remove("AWS_ACCESS_KEY_ID");
|
||||||
|
cmd.env_remove("AWS_SECRET_ACCESS_KEY");
|
||||||
|
|
||||||
|
// Execute with timeout (30 seconds)
|
||||||
|
let output = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
tokio::task::spawn_blocking(move || cmd.output()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Command execution timed out after 30 seconds".to_string())?
|
||||||
|
.map_err(|e| format!("Failed to spawn command: {e}"))?
|
||||||
|
.map_err(|e| format!("Failed to execute kubectl: {e}"))?;
|
||||||
|
|
||||||
|
let execution_time_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
Ok(CommandOutput {
|
||||||
|
exit_code: output.status.code().unwrap_or(-1),
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
execution_time_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_locate_kubectl_finds_binary() {
|
||||||
|
// Should find either bundled or system kubectl
|
||||||
|
// In CI environments without kubectl installed, this may fail gracefully
|
||||||
|
let result = locate_kubectl();
|
||||||
|
if result.is_ok() {
|
||||||
|
assert!(
|
||||||
|
result.unwrap().exists(),
|
||||||
|
"kubectl path should exist if found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Test passes whether kubectl is found or not - just verifying function doesn't panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_execute_kubectl_with_timeout() {
|
||||||
|
let result =
|
||||||
|
execute_kubectl(&["version".to_string(), "--client".to_string()], None, None).await;
|
||||||
|
// Should either succeed or timeout, not hang forever
|
||||||
|
assert!(result.is_ok() || result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_kubectl_command_simple() {
|
||||||
|
// Test helper function for parsing kubectl commands
|
||||||
|
let cmd = "kubectl get pods";
|
||||||
|
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||||
|
assert_eq!(parts[0], "kubectl");
|
||||||
|
assert_eq!(parts[1], "get");
|
||||||
|
assert_eq!(parts[2], "pods");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src-tauri/src/shell/mod.rs
Normal file
12
src-tauri/src/shell/mod.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
pub mod classifier;
|
||||||
|
pub mod executor;
|
||||||
|
pub mod kubeconfig;
|
||||||
|
pub mod kubectl;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use classifier::{ClassificationResult, CommandClassifier, CommandTier};
|
||||||
|
pub use executor::{execute_with_approval, CommandOutput};
|
||||||
|
pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo};
|
||||||
|
pub use kubectl::{execute_kubectl, locate_kubectl};
|
||||||
22
src-tauri/src/shell/tests.rs
Normal file
22
src-tauri/src/shell/tests.rs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Integration tests for shell module
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration_tests {
|
||||||
|
use crate::shell::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_module_exports() {
|
||||||
|
// Verify all public types are accessible
|
||||||
|
let _classifier = CommandClassifier::new();
|
||||||
|
|
||||||
|
// This test just ensures compilation succeeds and exports are correct
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_tier_enum() {
|
||||||
|
// Verify enum variants exist and can be compared
|
||||||
|
assert_eq!(CommandTier::Tier1, CommandTier::Tier1);
|
||||||
|
assert_ne!(CommandTier::Tier1, CommandTier::Tier2);
|
||||||
|
assert_ne!(CommandTier::Tier2, CommandTier::Tier3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::{oneshot, Mutex as TokioMutex};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderConfig {
|
||||||
@ -68,6 +68,13 @@ impl Default for AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response for shell command approval requests
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApprovalResponse {
|
||||||
|
pub approved: bool,
|
||||||
|
pub decision: String, // "deny", "allow_once", "allow_session"
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Mutex<rusqlite::Connection>>,
|
pub db: Arc<Mutex<rusqlite::Connection>>,
|
||||||
pub settings: Arc<Mutex<AppSettings>>,
|
pub settings: Arc<Mutex<AppSettings>>,
|
||||||
@ -77,6 +84,8 @@ pub struct AppState {
|
|||||||
/// Live MCP server connections: server_id -> connection
|
/// Live MCP server connections: server_id -> connection
|
||||||
pub mcp_connections:
|
pub mcp_connections:
|
||||||
Arc<TokioMutex<HashMap<String, Arc<TokioMutex<crate::mcp::client::McpConnection>>>>>,
|
Arc<TokioMutex<HashMap<String, Arc<TokioMutex<crate::mcp::client::McpConnection>>>>>,
|
||||||
|
/// Pending shell command approval requests: approval_id -> response channel
|
||||||
|
pub pending_approvals: Arc<TokioMutex<HashMap<String, oneshot::Sender<ApprovalResponse>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the application data directory.
|
/// Determine the application data directory.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user