diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..1ed56b9 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,7 +408,8 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) + .with_tool_requirement("add", PermissionMode::DangerFullAccess); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -487,7 +488,8 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite) + .with_tool_requirement("blocked", PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -536,7 +538,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::ReadOnly), vec!["system".to_string()], ); @@ -563,7 +565,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::ReadOnly), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/runtime/src/permissions.rs b/rust/crates/runtime/src/permissions.rs index 1846b3c..919730b 100644 --- a/rust/crates/runtime/src/permissions.rs +++ b/rust/crates/runtime/src/permissions.rs @@ -1,16 +1,29 @@ use std::collections::BTreeMap; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PermissionMode { - Allow, - Deny, - Prompt, + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl PermissionMode { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read-only", + Self::WorkspaceWrite => "workspace-write", + Self::DangerFullAccess => "danger-full-access", + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PermissionRequest { pub tool_name: String, pub input: String, + pub current_mode: PermissionMode, + pub required_mode: PermissionMode, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,31 +44,41 @@ pub enum PermissionOutcome { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PermissionPolicy { - default_mode: PermissionMode, - tool_modes: BTreeMap, + active_mode: PermissionMode, + tool_requirements: BTreeMap, } impl PermissionPolicy { #[must_use] - pub fn new(default_mode: PermissionMode) -> Self { + pub fn new(active_mode: PermissionMode) -> Self { Self { - default_mode, - tool_modes: BTreeMap::new(), + active_mode, + tool_requirements: BTreeMap::new(), } } #[must_use] - pub fn with_tool_mode(mut self, tool_name: impl Into, mode: PermissionMode) -> Self { - self.tool_modes.insert(tool_name.into(), mode); + pub fn with_tool_requirement( + mut self, + tool_name: impl Into, + required_mode: PermissionMode, + ) -> Self { + self.tool_requirements + .insert(tool_name.into(), required_mode); self } #[must_use] - pub fn mode_for(&self, tool_name: &str) -> PermissionMode { - self.tool_modes + pub fn active_mode(&self) -> PermissionMode { + self.active_mode + } + + #[must_use] + pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode { + self.tool_requirements .get(tool_name) .copied() - .unwrap_or(self.default_mode) + .unwrap_or(PermissionMode::DangerFullAccess) } #[must_use] @@ -65,23 +88,43 @@ impl PermissionPolicy { input: &str, mut prompter: Option<&mut dyn PermissionPrompter>, ) -> PermissionOutcome { - match self.mode_for(tool_name) { - PermissionMode::Allow => PermissionOutcome::Allow, - PermissionMode::Deny => PermissionOutcome::Deny { - reason: format!("tool '{tool_name}' denied by permission policy"), - }, - PermissionMode::Prompt => match prompter.as_mut() { - Some(prompter) => match prompter.decide(&PermissionRequest { - tool_name: tool_name.to_string(), - input: input.to_string(), - }) { + let current_mode = self.active_mode(); + let required_mode = self.required_mode_for(tool_name); + if current_mode >= required_mode { + return PermissionOutcome::Allow; + } + + let request = PermissionRequest { + tool_name: tool_name.to_string(), + input: input.to_string(), + current_mode, + required_mode, + }; + + if current_mode == PermissionMode::WorkspaceWrite + && required_mode == PermissionMode::DangerFullAccess + { + return match prompter.as_mut() { + Some(prompter) => match prompter.decide(&request) { PermissionPromptDecision::Allow => PermissionOutcome::Allow, PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, }, None => PermissionOutcome::Deny { - reason: format!("tool '{tool_name}' requires interactive approval"), + reason: format!( + "tool '{tool_name}' requires approval to escalate from {} to {}", + current_mode.as_str(), + required_mode.as_str() + ), }, - }, + }; + } + + PermissionOutcome::Deny { + reason: format!( + "tool '{tool_name}' requires {} permission; current mode is {}", + required_mode.as_str(), + current_mode.as_str() + ), } } } @@ -93,25 +136,92 @@ mod tests { PermissionPrompter, PermissionRequest, }; - struct AllowPrompter; + struct RecordingPrompter { + seen: Vec, + allow: bool, + } - impl PermissionPrompter for AllowPrompter { + impl PermissionPrompter for RecordingPrompter { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { - assert_eq!(request.tool_name, "bash"); - PermissionPromptDecision::Allow + self.seen.push(request.clone()); + if self.allow { + PermissionPromptDecision::Allow + } else { + PermissionPromptDecision::Deny { + reason: "not now".to_string(), + } + } } } #[test] - fn uses_tool_specific_overrides() { - let policy = PermissionPolicy::new(PermissionMode::Deny) - .with_tool_mode("bash", PermissionMode::Prompt); + fn allows_tools_when_active_mode_meets_requirement() { + let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) + .with_tool_requirement("read_file", PermissionMode::ReadOnly) + .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite); + + assert_eq!( + policy.authorize("read_file", "{}", None), + PermissionOutcome::Allow + ); + assert_eq!( + policy.authorize("write_file", "{}", None), + PermissionOutcome::Allow + ); + } + + #[test] + fn denies_read_only_escalations_without_prompt() { + let policy = PermissionPolicy::new(PermissionMode::ReadOnly) + .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess); - let outcome = policy.authorize("bash", "echo hi", Some(&mut AllowPrompter)); - assert_eq!(outcome, PermissionOutcome::Allow); assert!(matches!( - policy.authorize("edit", "x", None), - PermissionOutcome::Deny { .. } + policy.authorize("write_file", "{}", None), + PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission") + )); + assert!(matches!( + policy.authorize("bash", "{}", None), + PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission") + )); + } + + #[test] + fn prompts_for_workspace_write_to_danger_full_access_escalation() { + let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess); + let mut prompter = RecordingPrompter { + seen: Vec::new(), + allow: true, + }; + + let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter)); + + assert_eq!(outcome, PermissionOutcome::Allow); + assert_eq!(prompter.seen.len(), 1); + assert_eq!(prompter.seen[0].tool_name, "bash"); + assert_eq!( + prompter.seen[0].current_mode, + PermissionMode::WorkspaceWrite + ); + assert_eq!( + prompter.seen[0].required_mode, + PermissionMode::DangerFullAccess + ); + } + + #[test] + fn honors_prompt_rejection_reason() { + let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite) + .with_tool_requirement("bash", PermissionMode::DangerFullAccess); + let mut prompter = RecordingPrompter { + seen: Vec::new(), + allow: false, + }; + + assert!(matches!( + policy.authorize("bash", "echo hi", Some(&mut prompter)), + PermissionOutcome::Deny { reason } if reason == "not now" )); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3fc05da..1293d98 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -28,7 +28,7 @@ use runtime::{ Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; -use tools::{execute_tool, mvp_tool_specs}; +use tools::{execute_tool, mvp_tool_specs, ToolSpec}; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MAX_TOKENS: u32 = 32; @@ -67,14 +67,16 @@ fn run() -> Result<(), Box> { model, output_format, allowed_tools, - } => LiveCli::new(model, false, allowed_tools)? + permission_mode, + } => LiveCli::new(model, false, allowed_tools, permission_mode)? .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, CliAction::Repl { model, allowed_tools, - } => run_repl(model, allowed_tools)?, + permission_mode, + } => run_repl(model, allowed_tools, permission_mode)?, CliAction::Help => print_help(), } Ok(()) @@ -98,12 +100,14 @@ enum CliAction { model: String, output_format: CliOutputFormat, allowed_tools: Option, + permission_mode: PermissionMode, }, Login, Logout, Repl { model: String, allowed_tools: Option, + permission_mode: PermissionMode, }, // prompt-mode formatting is only supported for non-interactive runs Help, @@ -127,9 +131,11 @@ impl CliOutputFormat { } } +#[allow(clippy::too_many_lines)] fn parse_args(args: &[String]) -> Result { let mut model = DEFAULT_MODEL.to_string(); let mut output_format = CliOutputFormat::Text; + let mut permission_mode = default_permission_mode(); let mut wants_version = false; let mut allowed_tool_values = Vec::new(); let mut rest = Vec::new(); @@ -159,10 +165,21 @@ fn parse_args(args: &[String]) -> Result { output_format = CliOutputFormat::parse(value)?; index += 2; } + "--permission-mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --permission-mode".to_string())?; + permission_mode = parse_permission_mode_arg(value)?; + index += 2; + } flag if flag.starts_with("--output-format=") => { output_format = CliOutputFormat::parse(&flag[16..])?; index += 1; } + flag if flag.starts_with("--permission-mode=") => { + permission_mode = parse_permission_mode_arg(&flag[18..])?; + index += 1; + } "--allowedTools" | "--allowed-tools" => { let value = args .get(index + 1) @@ -195,6 +212,7 @@ fn parse_args(args: &[String]) -> Result { return Ok(CliAction::Repl { model, allowed_tools, + permission_mode, }); } if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { @@ -220,6 +238,7 @@ fn parse_args(args: &[String]) -> Result { model, output_format, allowed_tools, + permission_mode, }) } other if !other.starts_with('/') => Ok(CliAction::Prompt { @@ -227,6 +246,7 @@ fn parse_args(args: &[String]) -> Result { model, output_format, allowed_tools, + permission_mode, }), other => Err(format!("unknown subcommand: {other}")), } @@ -280,6 +300,33 @@ fn normalize_tool_name(value: &str) -> String { value.trim().replace('-', "_").to_ascii_lowercase() } +fn parse_permission_mode_arg(value: &str) -> Result { + normalize_permission_mode(value) + .ok_or_else(|| { + format!( + "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access." + ) + }) + .map(permission_mode_from_label) +} + +fn permission_mode_from_label(mode: &str) -> PermissionMode { + match mode { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported permission mode label: {other}"), + } +} + +fn default_permission_mode() -> PermissionMode { + env::var("RUSTY_CLAUDE_PERMISSION_MODE") + .ok() + .as_deref() + .and_then(normalize_permission_mode) + .map_or(PermissionMode::WorkspaceWrite, permission_mode_from_label) +} + fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec { mvp_tool_specs() .into_iter() @@ -786,7 +833,7 @@ fn run_resume_command( cumulative: usage, estimated_tokens: 0, }, - permission_mode_label(), + default_permission_mode().as_str(), &status_context(Some(session_path))?, )), }) @@ -841,8 +888,9 @@ fn run_resume_command( fn run_repl( model: String, allowed_tools: Option, + permission_mode: PermissionMode, ) -> Result<(), Box> { - let mut cli = LiveCli::new(model, true, allowed_tools)?; + let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; let editor = input::LineEditor::new("› "); println!("{}", cli.startup_banner()); @@ -881,6 +929,7 @@ struct ManagedSessionSummary { struct LiveCli { model: String, allowed_tools: Option, + permission_mode: PermissionMode, system_prompt: Vec, runtime: ConversationRuntime, session: SessionHandle, @@ -891,6 +940,7 @@ impl LiveCli { model: String, enable_tools: bool, allowed_tools: Option, + permission_mode: PermissionMode, ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; @@ -900,10 +950,12 @@ impl LiveCli { system_prompt.clone(), enable_tools, allowed_tools.clone(), + permission_mode, )?; let cli = Self { model, allowed_tools, + permission_mode, system_prompt, runtime, session, @@ -914,8 +966,9 @@ impl LiveCli { fn startup_banner(&self) -> String { format!( - "Rusty Claude CLI\n Model {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.", + "Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.", self.model, + self.permission_mode.as_str(), env::current_dir().map_or_else( |_| "".to_string(), |path| path.display().to_string(), @@ -932,7 +985,8 @@ impl LiveCli { TerminalRenderer::new().color_theme(), &mut stdout, )?; - let result = self.runtime.run_turn(input, None); + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); match result { Ok(_) => { spinner.finish( @@ -1055,7 +1109,7 @@ impl LiveCli { cumulative, estimated_tokens: self.runtime.estimated_tokens(), }, - permission_mode_label(), + self.permission_mode.as_str(), &status_context(Some(&self.session.path)).expect("status context should load"), ) ); @@ -1095,6 +1149,7 @@ impl LiveCli { self.system_prompt.clone(), true, self.allowed_tools.clone(), + self.permission_mode, )?; self.model.clone_from(&model); self.persist_session()?; @@ -1107,7 +1162,10 @@ impl LiveCli { fn set_permissions(&mut self, mode: Option) -> Result<(), Box> { let Some(mode) = mode else { - println!("{}", format_permissions_report(permission_mode_label())); + println!( + "{}", + format_permissions_report(self.permission_mode.as_str()) + ); return Ok(()); }; @@ -1117,20 +1175,21 @@ impl LiveCli { ) })?; - if normalized == permission_mode_label() { + if normalized == self.permission_mode.as_str() { println!("{}", format_permissions_report(normalized)); return Ok(()); } - let previous = permission_mode_label().to_string(); + let previous = self.permission_mode.as_str().to_string(); let session = self.runtime.session().clone(); - self.runtime = build_runtime_with_permission_mode( + self.permission_mode = permission_mode_from_label(normalized); + self.runtime = build_runtime( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), - normalized, + self.permission_mode, )?; self.persist_session()?; println!( @@ -1149,19 +1208,19 @@ impl LiveCli { } self.session = create_managed_session_handle()?; - self.runtime = build_runtime_with_permission_mode( + self.runtime = build_runtime( Session::new(), self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), - permission_mode_label(), + self.permission_mode, )?; self.persist_session()?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, - permission_mode_label(), + self.permission_mode.as_str(), self.session.id, ); Ok(()) @@ -1184,13 +1243,13 @@ impl LiveCli { let handle = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); - self.runtime = build_runtime_with_permission_mode( + self.runtime = build_runtime( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), - permission_mode_label(), + self.permission_mode, )?; self.session = handle; self.persist_session()?; @@ -1261,13 +1320,13 @@ impl LiveCli { let handle = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); - self.runtime = build_runtime_with_permission_mode( + self.runtime = build_runtime( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), - permission_mode_label(), + self.permission_mode, )?; self.session = handle; self.persist_session()?; @@ -1291,13 +1350,13 @@ impl LiveCli { let removed = result.removed_message_count; let kept = result.compacted_session.messages.len(); let skipped = removed == 0; - self.runtime = build_runtime_with_permission_mode( + self.runtime = build_runtime( result.compacted_session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), - permission_mode_label(), + self.permission_mode, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); @@ -1686,14 +1745,6 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> { } } -fn permission_mode_label() -> &'static str { - match env::var("RUSTY_CLAUDE_PERMISSION_MODE") { - Ok(value) if value == "read-only" => "read-only", - Ok(value) if value == "danger-full-access" => "danger-full-access", - _ => "workspace-write", - } -} - fn render_diff_report() -> Result> { let output = std::process::Command::new("git") .args(["diff", "--", ":(exclude).omx"]) @@ -1823,25 +1874,7 @@ fn build_runtime( system_prompt: Vec, enable_tools: bool, allowed_tools: Option, -) -> Result, Box> -{ - build_runtime_with_permission_mode( - session, - model, - system_prompt, - enable_tools, - allowed_tools, - permission_mode_label(), - ) -} - -fn build_runtime_with_permission_mode( - session: Session, - model: String, - system_prompt: Vec, - enable_tools: bool, - allowed_tools: Option, - permission_mode: &str, + permission_mode: PermissionMode, ) -> Result, Box> { Ok(ConversationRuntime::new( @@ -1853,6 +1886,52 @@ fn build_runtime_with_permission_mode( )) } +struct CliPermissionPrompter { + current_mode: PermissionMode, +} + +impl CliPermissionPrompter { + fn new(current_mode: PermissionMode) -> Self { + Self { current_mode } + } +} + +impl runtime::PermissionPrompter for CliPermissionPrompter { + fn decide( + &mut self, + request: &runtime::PermissionRequest, + ) -> runtime::PermissionPromptDecision { + println!(); + println!("Permission approval required"); + println!(" Tool {}", request.tool_name); + println!(" Current mode {}", self.current_mode.as_str()); + println!(" Required mode {}", request.required_mode.as_str()); + println!(" Input {}", request.input); + print!("Approve this tool call? [y/N]: "); + let _ = io::stdout().flush(); + + let mut response = String::new(); + match io::stdin().read_line(&mut response) { + Ok(_) => { + let normalized = response.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "y" | "yes") { + runtime::PermissionPromptDecision::Allow + } else { + runtime::PermissionPromptDecision::Deny { + reason: format!( + "tool '{}' denied by user approval prompt", + request.tool_name + ), + } + } + } + Err(error) => runtime::PermissionPromptDecision::Deny { + reason: format!("permission approval failed: {error}"), + }, + } + } +} + struct AnthropicRuntimeClient { runtime: tokio::runtime::Runtime, client: AnthropicClient, @@ -2096,15 +2175,16 @@ impl ToolExecutor for CliToolExecutor { } } -fn permission_policy(mode: &str) -> PermissionPolicy { - if normalize_permission_mode(mode) == Some("read-only") { - PermissionPolicy::new(PermissionMode::Deny) - .with_tool_mode("read_file", PermissionMode::Allow) - .with_tool_mode("glob_search", PermissionMode::Allow) - .with_tool_mode("grep_search", PermissionMode::Allow) - } else { - PermissionPolicy::new(PermissionMode::Allow) - } +fn permission_policy(mode: PermissionMode) -> PermissionPolicy { + tool_permission_specs() + .into_iter() + .fold(PermissionPolicy::new(mode), |policy, spec| { + policy.with_tool_requirement(spec.name, spec.required_permission) + }) +} + +fn tool_permission_specs() -> Vec { + mvp_tool_specs() } fn convert_messages(messages: &[ConversationMessage]) -> Vec { @@ -2169,6 +2249,7 @@ fn print_help() { println!("Flags:"); println!(" --model MODEL Override the active model"); println!(" --output-format FORMAT Non-interactive output format: text or json"); + println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); println!(" --version, -V Print version and build information locally"); println!(); @@ -2203,7 +2284,7 @@ mod tests { resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; - use runtime::{ContentBlock, ConversationMessage, MessageRole}; + use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use std::path::{Path, PathBuf}; #[test] @@ -2213,6 +2294,7 @@ mod tests { CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, } ); } @@ -2231,6 +2313,7 @@ mod tests { model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, } ); } @@ -2251,6 +2334,7 @@ mod tests { model: "claude-opus".to_string(), output_format: CliOutputFormat::Json, allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, } ); } @@ -2267,6 +2351,19 @@ mod tests { ); } + #[test] + fn parses_permission_mode_flag() { + let args = vec!["--permission-mode=read-only".to_string()]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Repl { + model: DEFAULT_MODEL.to_string(), + allowed_tools: None, + permission_mode: PermissionMode::ReadOnly, + } + ); + } + #[test] fn parses_allowed_tools_flags_with_aliases_and_lists() { let args = vec![ @@ -2284,6 +2381,7 @@ mod tests { .map(str::to_string) .collect() ), + permission_mode: PermissionMode::WorkspaceWrite, } ); } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 14590ac..2182b05 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput, - GrepSearchInput, + GrepSearchInput, PermissionMode, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -45,6 +45,7 @@ pub struct ToolSpec { pub name: &'static str, pub description: &'static str, pub input_schema: Value, + pub required_permission: PermissionMode, } #[must_use] @@ -66,6 +67,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["command"], "additionalProperties": false }), + required_permission: PermissionMode::DangerFullAccess, }, ToolSpec { name: "read_file", @@ -80,6 +82,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["path"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "write_file", @@ -93,6 +96,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["path", "content"], "additionalProperties": false }), + required_permission: PermissionMode::WorkspaceWrite, }, ToolSpec { name: "edit_file", @@ -108,6 +112,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["path", "old_string", "new_string"], "additionalProperties": false }), + required_permission: PermissionMode::WorkspaceWrite, }, ToolSpec { name: "glob_search", @@ -121,6 +126,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["pattern"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "grep_search", @@ -146,6 +152,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["pattern"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "WebFetch", @@ -160,6 +167,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["url", "prompt"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "WebSearch", @@ -180,6 +188,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["query"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "TodoWrite", @@ -207,6 +216,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["todos"], "additionalProperties": false }), + required_permission: PermissionMode::WorkspaceWrite, }, ToolSpec { name: "Skill", @@ -220,6 +230,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["skill"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "Agent", @@ -236,6 +247,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["description", "prompt"], "additionalProperties": false }), + required_permission: PermissionMode::DangerFullAccess, }, ToolSpec { name: "ToolSearch", @@ -249,6 +261,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["query"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "NotebookEdit", @@ -265,6 +278,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["notebook_path"], "additionalProperties": false }), + required_permission: PermissionMode::WorkspaceWrite, }, ToolSpec { name: "Sleep", @@ -277,6 +291,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["duration_ms"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "SendUserMessage", @@ -297,6 +312,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["message", "status"], "additionalProperties": false }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "Config", @@ -312,6 +328,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["setting"], "additionalProperties": false }), + required_permission: PermissionMode::WorkspaceWrite, }, ToolSpec { name: "StructuredOutput", @@ -320,6 +337,7 @@ pub fn mvp_tool_specs() -> Vec { "type": "object", "additionalProperties": true }), + required_permission: PermissionMode::ReadOnly, }, ToolSpec { name: "REPL", @@ -334,6 +352,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["code", "language"], "additionalProperties": false }), + required_permission: PermissionMode::DangerFullAccess, }, ToolSpec { name: "PowerShell", @@ -349,6 +368,7 @@ pub fn mvp_tool_specs() -> Vec { "required": ["command"], "additionalProperties": false }), + required_permission: PermissionMode::DangerFullAccess, }, ] }