From e84133527e9427f5d408ec5e0c710d0642a0c000 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 23:38:53 +0000 Subject: [PATCH] Keep CLI parity features local and controllable The remaining slash commands already existed in the REPL path, so this change focuses on wiring the active CLI parser and runtime to expose them safely. `--version` now exits through a local reporting path, and `--allowedTools` constrains both advertised and executable tools without changing the underlying command surface. Constraint: The active CLI parser lives in main.rs, so a full parser unification would be broader than requested Constraint: --version must not require API credentials or construct the API client Rejected: Migrate the binary to the clap parser in args.rs | too large for a parity patch Rejected: Enforce allowed tools only at request construction time | execution-time mismatch risk Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep local-only flags like --version on pre-runtime codepaths and mirror tool allowlists in both definition and execution paths Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test; cargo run -q -p rusty-claude-cli -- --version; cargo run -q -p rusty-claude-cli -- --help Not-tested: Interactive live API conversation with restricted tool allowlists --- rust/README.md | 4 +- rust/crates/rusty-claude-cli/src/main.rs | 243 +++++++++++++++++++++-- 2 files changed, 227 insertions(+), 20 deletions(-) diff --git a/rust/README.md b/rust/README.md index dadefe3..52b798d 100644 --- a/rust/README.md +++ b/rust/README.md @@ -132,7 +132,9 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config - `bootstrap-plan` — print the current bootstrap skeleton - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `--help` / `-h` — show CLI help -- `--version` / `-V` — print the CLI version +- `--version` / `-V` — print the CLI version and build info locally (no API call) +- `--output-format text|json` — choose non-interactive prompt output rendering +- `--allowedTools ` — restrict enabled tools for interactive sessions and prompt-mode tool use ### Interactive slash commands diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index afbd550..a8a28bd 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1,6 +1,7 @@ mod input; mod render; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::io::{self, Write}; @@ -32,6 +33,8 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +type AllowedToolSet = BTreeSet; + fn main() { if let Err(error) = run() { eprintln!( @@ -49,6 +52,7 @@ fn run() -> Result<(), Box> { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), + CliAction::Version => print_version(), CliAction::ResumeSession { session_path, commands, @@ -57,8 +61,13 @@ fn run() -> Result<(), Box> { prompt, model, output_format, - } => LiveCli::new(model, false)?.run_turn_with_output(&prompt, output_format)?, - CliAction::Repl { model } => run_repl(model)?, + allowed_tools, + } => LiveCli::new(model, false, allowed_tools)? + .run_turn_with_output(&prompt, output_format)?, + CliAction::Repl { + model, + allowed_tools, + } => run_repl(model, allowed_tools)?, CliAction::Help => print_help(), } Ok(()) @@ -72,6 +81,7 @@ enum CliAction { cwd: PathBuf, date: String, }, + Version, ResumeSession { session_path: PathBuf, commands: Vec, @@ -80,9 +90,11 @@ enum CliAction { prompt: String, model: String, output_format: CliOutputFormat, + allowed_tools: Option, }, Repl { model: String, + allowed_tools: Option, }, // prompt-mode formatting is only supported for non-interactive runs Help, @@ -109,11 +121,17 @@ impl CliOutputFormat { fn parse_args(args: &[String]) -> Result { let mut model = DEFAULT_MODEL.to_string(); let mut output_format = CliOutputFormat::Text; + let mut wants_version = false; + let mut allowed_tool_values = Vec::new(); let mut rest = Vec::new(); let mut index = 0; while index < args.len() { match args[index].as_str() { + "--version" | "-V" => { + wants_version = true; + index += 1; + } "--model" => { let value = args .get(index + 1) @@ -136,6 +154,21 @@ fn parse_args(args: &[String]) -> Result { output_format = CliOutputFormat::parse(&flag[16..])?; index += 1; } + "--allowedTools" | "--allowed-tools" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --allowedTools".to_string())?; + allowed_tool_values.push(value.clone()); + index += 2; + } + flag if flag.starts_with("--allowedTools=") => { + allowed_tool_values.push(flag[15..].to_string()); + index += 1; + } + flag if flag.starts_with("--allowed-tools=") => { + allowed_tool_values.push(flag[16..].to_string()); + index += 1; + } other => { rest.push(other.to_string()); index += 1; @@ -143,8 +176,17 @@ fn parse_args(args: &[String]) -> Result { } } + if wants_version { + return Ok(CliAction::Version); + } + + let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + if rest.is_empty() { - return Ok(CliAction::Repl { model }); + return Ok(CliAction::Repl { + model, + allowed_tools, + }); } if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { return Ok(CliAction::Help); @@ -166,17 +208,74 @@ fn parse_args(args: &[String]) -> Result { prompt, model, output_format, + allowed_tools, }) } other if !other.starts_with('/') => Ok(CliAction::Prompt { prompt: rest.join(" "), model, output_format, + allowed_tools, }), other => Err(format!("unknown subcommand: {other}")), } } +fn normalize_allowed_tools(values: &[String]) -> Result, String> { + if values.is_empty() { + return Ok(None); + } + + let canonical_names = mvp_tool_specs() + .into_iter() + .map(|spec| spec.name.to_string()) + .collect::>(); + let mut name_map = canonical_names + .iter() + .map(|name| (normalize_tool_name(name), name.clone())) + .collect::>(); + + for (alias, canonical) in [ + ("read", "read_file"), + ("write", "write_file"), + ("edit", "edit_file"), + ("glob", "glob_search"), + ("grep", "grep_search"), + ] { + name_map.insert(alias.to_string(), canonical.to_string()); + } + + let mut allowed = AllowedToolSet::new(); + for value in values { + for token in value + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|token| !token.is_empty()) + { + let normalized = normalize_tool_name(token); + let canonical = name_map.get(&normalized).ok_or_else(|| { + format!( + "unsupported tool in --allowedTools: {token} (expected one of: {})", + canonical_names.join(", ") + ) + })?; + allowed.insert(canonical.clone()); + } + } + + Ok(Some(allowed)) +} + +fn normalize_tool_name(value: &str) -> String { + value.trim().replace('-', "_").to_ascii_lowercase() +} + +fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec { + mvp_tool_specs() + .into_iter() + .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) + .collect() +} + fn parse_system_prompt_args(args: &[String]) -> Result { let mut cwd = env::current_dir().map_err(|error| error.to_string())?; let mut date = DEFAULT_DATE.to_string(); @@ -255,6 +354,10 @@ fn print_system_prompt(cwd: PathBuf, date: String) { } } +fn print_version() { + println!("{}", render_version_report()); +} + fn resume_session(session_path: &Path, commands: &[String]) { let session = match Session::load_from_path(session_path) { Ok(session) => session, @@ -608,8 +711,11 @@ fn run_resume_command( } } -fn run_repl(model: String) -> Result<(), Box> { - let mut cli = LiveCli::new(model, true)?; +fn run_repl( + model: String, + allowed_tools: Option, +) -> Result<(), Box> { + let mut cli = LiveCli::new(model, true, allowed_tools)?; let editor = input::LineEditor::new("› "); println!("{}", cli.startup_banner()); @@ -647,13 +753,18 @@ struct ManagedSessionSummary { struct LiveCli { model: String, + allowed_tools: Option, system_prompt: Vec, runtime: ConversationRuntime, session: SessionHandle, } impl LiveCli { - fn new(model: String, enable_tools: bool) -> Result> { + fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; let runtime = build_runtime( @@ -661,9 +772,11 @@ impl LiveCli { model.clone(), system_prompt.clone(), enable_tools, + allowed_tools.clone(), )?; let cli = Self { model, + allowed_tools, system_prompt, runtime, session, @@ -849,7 +962,13 @@ impl LiveCli { let previous = self.model.clone(); let session = self.runtime.session().clone(); let message_count = session.messages.len(); - self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?; + self.runtime = build_runtime( + session, + model.clone(), + self.system_prompt.clone(), + true, + self.allowed_tools.clone(), + )?; self.model.clone_from(&model); self.persist_session()?; println!( @@ -883,6 +1002,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + self.allowed_tools.clone(), normalized, )?; self.persist_session()?; @@ -907,6 +1027,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + self.allowed_tools.clone(), permission_mode_label(), )?; self.persist_session()?; @@ -941,6 +1062,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + self.allowed_tools.clone(), permission_mode_label(), )?; self.session = handle; @@ -1017,6 +1139,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + self.allowed_tools.clone(), permission_mode_label(), )?; self.session = handle; @@ -1046,6 +1169,7 @@ impl LiveCli { self.model.clone(), self.system_prompt.clone(), true, + self.allowed_tools.clone(), permission_mode_label(), )?; self.persist_session()?; @@ -1571,6 +1695,7 @@ fn build_runtime( model: String, system_prompt: Vec, enable_tools: bool, + allowed_tools: Option, ) -> Result, Box> { build_runtime_with_permission_mode( @@ -1578,6 +1703,7 @@ fn build_runtime( model, system_prompt, enable_tools, + allowed_tools, permission_mode_label(), ) } @@ -1587,13 +1713,14 @@ fn build_runtime_with_permission_mode( model: String, system_prompt: Vec, enable_tools: bool, + allowed_tools: Option, permission_mode: &str, ) -> Result, Box> { Ok(ConversationRuntime::new( session, - AnthropicRuntimeClient::new(model, enable_tools)?, - CliToolExecutor::new(), + AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone())?, + CliToolExecutor::new(allowed_tools), permission_policy(permission_mode), system_prompt, )) @@ -1604,15 +1731,21 @@ struct AnthropicRuntimeClient { client: AnthropicClient, model: String, enable_tools: bool, + allowed_tools: Option, } impl AnthropicRuntimeClient { - fn new(model: String, enable_tools: bool) -> Result> { + fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + ) -> Result> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, client: AnthropicClient::from_env()?, model, enable_tools, + allowed_tools, }) } } @@ -1626,7 +1759,7 @@ impl ApiClient for AnthropicRuntimeClient { messages: convert_messages(&request.messages), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), tools: self.enable_tools.then(|| { - mvp_tool_specs() + filter_tool_specs(self.allowed_tools.as_ref()) .into_iter() .map(|spec| ToolDefinition { name: spec.name.to_string(), @@ -1781,18 +1914,29 @@ fn response_to_events( struct CliToolExecutor { renderer: TerminalRenderer, + allowed_tools: Option, } impl CliToolExecutor { - fn new() -> Self { + fn new(allowed_tools: Option) -> Self { Self { renderer: TerminalRenderer::new(), + allowed_tools, } } } impl ToolExecutor for CliToolExecutor { fn execute(&mut self, tool_name: &str, input: &str) -> Result { + if self + .allowed_tools + .as_ref() + .is_some_and(|allowed| !allowed.contains(tool_name)) + { + return Err(ToolError::new(format!( + "tool `{tool_name}` is not enabled by the current --allowedTools setting" + ))); + } let value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; match execute_tool(tool_name, &value) { @@ -1864,7 +2008,7 @@ fn print_help() { println!("rusty-claude-cli v{VERSION}"); println!(); println!("Usage:"); - println!(" rusty-claude-cli [--model MODEL]"); + println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]"); println!(" Start the interactive REPL"); println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT"); println!(" Send one prompt and exit"); @@ -1879,6 +2023,8 @@ fn print_help() { println!("Flags:"); println!(" --model MODEL Override the active model"); println!(" --output-format FORMAT Non-interactive output format: text or json"); + println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); + println!(" --version, -V Print version and build information locally"); println!(); println!("Interactive slash commands:"); println!("{}", render_slash_command_help()); @@ -1895,18 +2041,20 @@ fn print_help() { println!("Examples:"); println!(" rusty-claude-cli --model claude-opus \"summarize this repo\""); println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\""); + println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\""); println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt"); } #[cfg(test)] mod tests { use super::{ - format_compact_report, format_cost_report, format_init_report, format_model_report, - format_model_switch_report, format_permissions_report, format_permissions_switch_report, - format_resume_report, format_status_report, normalize_permission_mode, parse_args, - parse_git_status_metadata, render_config_report, render_init_claude_md, - render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + filter_tool_specs, format_compact_report, format_cost_report, format_init_report, + format_model_report, format_model_switch_report, format_permissions_report, + format_permissions_switch_report, format_resume_report, format_status_report, + normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, + render_init_claude_md, render_memory_report, render_repl_help, + resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, + StatusUsage, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::{Path, PathBuf}; @@ -1917,6 +2065,7 @@ mod tests { parse_args(&[]).expect("args should parse"), CliAction::Repl { model: DEFAULT_MODEL.to_string(), + allowed_tools: None, } ); } @@ -1934,6 +2083,7 @@ mod tests { prompt: "hello world".to_string(), model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, + allowed_tools: None, } ); } @@ -1953,10 +2103,51 @@ mod tests { prompt: "explain this".to_string(), model: "claude-opus".to_string(), output_format: CliOutputFormat::Json, + allowed_tools: None, } ); } + #[test] + fn parses_version_flags_without_initializing_prompt_mode() { + assert_eq!( + parse_args(&["--version".to_string()]).expect("args should parse"), + CliAction::Version + ); + assert_eq!( + parse_args(&["-V".to_string()]).expect("args should parse"), + CliAction::Version + ); + } + + #[test] + fn parses_allowed_tools_flags_with_aliases_and_lists() { + let args = vec![ + "--allowedTools".to_string(), + "read,glob".to_string(), + "--allowed-tools=write_file".to_string(), + ]; + assert_eq!( + parse_args(&args).expect("args should parse"), + CliAction::Repl { + model: DEFAULT_MODEL.to_string(), + allowed_tools: Some( + ["glob_search", "read_file", "write_file"] + .into_iter() + .map(str::to_string) + .collect() + ), + } + ); + } + + #[test] + fn rejects_unknown_allowed_tools() { + let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()]) + .expect_err("tool should be rejected"); + assert!(error.contains("unsupported tool in --allowedTools: teleport")); + } + #[test] fn parses_system_prompt_options() { let args = vec![ @@ -2013,6 +2204,20 @@ mod tests { ); } + #[test] + fn filtered_tool_specs_respect_allowlist() { + let allowed = ["read_file", "grep_search"] + .into_iter() + .map(str::to_string) + .collect(); + let filtered = filter_tool_specs(Some(&allowed)); + let names = filtered + .into_iter() + .map(|spec| spec.name) + .collect::>(); + assert_eq!(names, vec!["read_file", "grep_search"]); + } + #[test] fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help();