use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RiskLevel {
Low,
Medium,
High,
}
impl RiskLevel {
pub fn as_str(&self) -> &'static str {
match self {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
}
}
pub fn color_code(&self) -> &'static str {
match self {
RiskLevel::Low => "\x1b[32m", RiskLevel::Medium => "\x1b[33m", RiskLevel::High => "\x1b[31m", }
}
}
impl std::fmt::Display for RiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub enum ExecutionMode {
#[default]
Normal,
Plan,
Auto,
Yolo,
}
impl ExecutionMode {
pub fn as_str(&self) -> &'static str {
match self {
ExecutionMode::Normal => "normal",
ExecutionMode::Plan => "plan",
ExecutionMode::Auto => "auto",
ExecutionMode::Yolo => "yolo",
}
}
}
impl std::fmt::Display for ExecutionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionResult {
Allow,
Deny { reason: String },
Prompt { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolMetadata {
pub read_only: bool,
pub destructive: bool,
pub risk_level: RiskLevel,
pub network_access: bool,
pub shell_execution: bool,
}
impl ToolMetadata {
pub fn read_only() -> Self {
Self {
read_only: true,
destructive: false,
risk_level: RiskLevel::Low,
network_access: false,
shell_execution: false,
}
}
pub fn file_write() -> Self {
Self {
read_only: false,
destructive: false,
risk_level: RiskLevel::Medium,
network_access: false,
shell_execution: false,
}
}
pub fn file_destructive() -> Self {
Self {
read_only: false,
destructive: true,
risk_level: RiskLevel::High,
network_access: false,
shell_execution: false,
}
}
pub fn shell() -> Self {
Self {
read_only: false,
destructive: true,
risk_level: RiskLevel::High,
network_access: false,
shell_execution: true,
}
}
pub fn network() -> Self {
Self {
read_only: true,
destructive: false,
risk_level: RiskLevel::Medium,
network_access: true,
shell_execution: false,
}
}
pub fn git() -> Self {
Self {
read_only: false,
destructive: false,
risk_level: RiskLevel::Medium,
network_access: false,
shell_execution: false,
}
}
pub fn custom(
read_only: bool,
destructive: bool,
risk_level: RiskLevel,
network_access: bool,
shell_execution: bool,
) -> Self {
Self {
read_only,
destructive,
risk_level,
network_access,
shell_execution,
}
}
}
#[derive(Debug, Clone)]
pub struct PermissionChecker {
mode: ExecutionMode,
allow_destructive_in_yolo: bool,
allow_network: bool,
}
impl Default for PermissionChecker {
fn default() -> Self {
Self::new(ExecutionMode::Normal)
}
}
impl PermissionChecker {
pub fn new(mode: ExecutionMode) -> Self {
Self {
mode,
allow_destructive_in_yolo: false,
allow_network: true,
}
}
pub fn mode(&self) -> ExecutionMode {
self.mode
}
pub fn check(
&self,
tool_name: &str,
metadata: &ToolMetadata,
_input: &Value,
) -> PermissionResult {
match self.mode {
ExecutionMode::Plan => self.check_plan_mode(metadata),
ExecutionMode::Normal => self.check_normal_mode(metadata),
ExecutionMode::Auto => self.check_auto_mode(tool_name, metadata),
ExecutionMode::Yolo => self.check_yolo_mode(tool_name, metadata),
}
}
fn check_plan_mode(&self, metadata: &ToolMetadata) -> PermissionResult {
if metadata.read_only {
PermissionResult::Allow
} else {
PermissionResult::Deny {
reason: format!(
"Plan mode only allows read-only operations. '{}' is a modifying tool.",
if metadata.destructive {
"destructive"
} else {
"modifying"
}
),
}
}
}
fn check_normal_mode(&self, metadata: &ToolMetadata) -> PermissionResult {
match metadata.risk_level {
RiskLevel::Low => PermissionResult::Allow,
RiskLevel::Medium => PermissionResult::Prompt {
reason: "This operation may modify files or state".to_string(),
},
RiskLevel::High => PermissionResult::Prompt {
reason: if metadata.destructive {
"This is a destructive operation that may cause data loss".to_string()
} else if metadata.shell_execution {
"This executes a shell command which may be dangerous".to_string()
} else {
"This is a high-risk operation".to_string()
},
},
}
}
fn check_auto_mode(&self, tool_name: &str, metadata: &ToolMetadata) -> PermissionResult {
match metadata.risk_level {
RiskLevel::Low | RiskLevel::Medium => PermissionResult::Allow,
RiskLevel::High => {
if metadata.destructive && is_protected_tool(tool_name) {
PermissionResult::Prompt {
reason: format!(
"{} is a destructive operation that requires confirmation",
tool_name
),
}
} else {
PermissionResult::Allow
}
}
}
}
fn check_yolo_mode(&self, _tool_name: &str, metadata: &ToolMetadata) -> PermissionResult {
if metadata.network_access && !self.allow_network {
return PermissionResult::Prompt {
reason: "Network operations require confirmation".to_string(),
};
}
if metadata.destructive && !self.allow_destructive_in_yolo {
return PermissionResult::Prompt {
reason: "Destructive operations require confirmation in YOLO mode".to_string(),
};
}
PermissionResult::Allow
}
pub fn is_read_only(&self, metadata: &ToolMetadata) -> bool {
metadata.read_only
}
pub fn risk_level(&self, metadata: &ToolMetadata) -> RiskLevel {
metadata.risk_level
}
}
fn is_protected_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"file_delete" | "shell_exec" | "container_remove" | "compose_down"
)
}
pub fn classify_tool_metadata(tool_name: &str) -> Option<ToolMetadata> {
let meta = match tool_name {
"file_read" | "directory_tree" => ToolMetadata::read_only(),
"file_write" | "file_edit" => ToolMetadata::file_write(),
"file_delete" => ToolMetadata::file_destructive(),
"grep_search" | "glob_find" | "symbol_search" | "tool_search" => ToolMetadata::read_only(),
"shell_exec" | "pty_shell" => ToolMetadata::shell(),
"git_status" | "git_diff" => ToolMetadata::read_only(),
"git_commit" | "git_push" | "git_checkpoint" => ToolMetadata::git(),
"cargo_test" | "cargo_check" | "cargo_clippy" | "cargo_fmt" => {
ToolMetadata::custom(false, false, RiskLevel::Medium, false, false)
}
"http_request" => ToolMetadata::network(),
"container_run" | "container_exec" => ToolMetadata::shell(),
"container_list" | "container_logs" | "container_images" => ToolMetadata::read_only(),
"container_stop" | "container_remove" | "compose_up" | "compose_down" => {
ToolMetadata::custom(false, true, RiskLevel::High, false, false)
}
"browser_fetch" | "browser_screenshot" | "browser_pdf" | "browser_eval"
| "browser_links" => ToolMetadata::network(),
"process_list" | "port_check" => ToolMetadata::read_only(),
"process_start" | "process_stop" | "process_restart" | "process_logs" => {
ToolMetadata::custom(false, false, RiskLevel::Medium, false, false)
}
"npm_install" | "npm_run" | "pip_install" | "yarn_install" => {
ToolMetadata::custom(false, false, RiskLevel::Medium, true, false)
}
"npm_scripts" | "pip_list" | "pip_freeze" => ToolMetadata::read_only(),
"knowledge_query" | "knowledge_stats" | "knowledge_export" => ToolMetadata::read_only(),
"knowledge_add" | "knowledge_relate" | "knowledge_clear" | "knowledge_remove" => {
ToolMetadata::file_write()
}
"knowledge_auto_extract" => ToolMetadata::read_only(),
"computer_mouse" | "computer_keyboard" | "computer_screen" | "computer_window" => {
ToolMetadata::custom(false, false, RiskLevel::High, false, false)
}
"screen_capture" => ToolMetadata::read_only(),
"vision_analyze" | "vision_compare" => ToolMetadata::read_only(),
"lsp_goto" | "lsp_references" | "lsp_symbols" | "lsp_hover" => ToolMetadata::read_only(),
"code_introspect" | "code_query" | "code_plan" | "code_diff_plan" => {
ToolMetadata::read_only()
}
"code_metrics" => ToolMetadata::read_only(),
"code_map" | "context_budget" | "context_action" => ToolMetadata::read_only(),
"file_multi_edit" | "patch_apply" => ToolMetadata::file_write(),
"lsp_diagnostics"
| "lsp_goto_definition"
| "lsp_goto_implementation"
| "lsp_find_references"
| "lsp_document_symbols"
| "lsp_workspace_symbols" => ToolMetadata::read_only(),
"localize_issue" | "ask_user" => ToolMetadata::read_only(),
"page_control" => ToolMetadata::network(),
"container_build" => ToolMetadata::shell(),
"container_pull" => ToolMetadata::network(),
"list_worktrees" => ToolMetadata::read_only(),
"enter_worktree" | "exit_worktree" => {
ToolMetadata::custom(false, false, RiskLevel::Medium, false, false)
}
"hot_reload" => ToolMetadata::custom(false, false, RiskLevel::Medium, false, false),
_ => return None,
};
Some(meta)
}
pub fn default_tool_metadata(tool_name: &str) -> ToolMetadata {
classify_tool_metadata(tool_name)
.unwrap_or_else(|| ToolMetadata::custom(false, false, RiskLevel::Medium, false, false))
}
pub fn normal_mode_needs_confirmation(
tool_name: &str,
require_confirmation: &[String],
grants: &crate::safety::permissions::PermissionStore,
) -> bool {
if grants.is_authorized(tool_name, None) {
return false;
}
if require_confirmation.iter().any(|t| t == tool_name) {
return true;
}
match classify_tool_metadata(tool_name) {
Some(meta) => !(meta.read_only && meta.risk_level == RiskLevel::Low),
None => true,
}
}
#[cfg(test)]
#[path = "../../tests/unit/safety/tool_metadata/tool_metadata_test.rs"]
mod tests;