diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6f89fe19..23dfe719 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -325,6 +325,9 @@ pub struct EngineConfig { /// Tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. pub allowed_tools: Option>, + /// Tool deny-list. Deny always wins over allow (#3027). + /// `None` means no tools are explicitly denied. + pub disallowed_tools: Option>, /// Hook executor for control-plane hooks. /// `ToolCallBefore` hooks may deny a tool call with exit code 2. pub hook_executor: Option>, @@ -409,6 +412,7 @@ impl Default for EngineConfig { strict_tool_mode: false, goal_objective: None, allowed_tools: None, + disallowed_tools: None, hook_executor: None, locale_tag: "en".to_string(), workshop: None, diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 53cdf927..631c3a7b 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -2378,6 +2378,15 @@ fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> boo allowed_tools.contains(&tool_name.to_ascii_lowercase()) } +/// Check whether `tool_name` is explicitly denied (#3027). +/// Deny always wins over allow. +fn command_denies_tool(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool { + let Some(disallowed_tools) = disallowed_tools else { + return false; + }; + disallowed_tools.contains(&tool_name.to_ascii_lowercase()) +} + fn resolve_tool_definition<'a>( tool_name: &mut String, tool_catalog: &'a [Tool], diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 8ac5f8f6..6b0b526e 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -336,6 +336,19 @@ struct ExecArgs { /// Output format for exec mode #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)] output_format: ExecOutputFormat, + /// Comma-separated list of tools to allow (all others denied). + /// Lowercase catalog names: read_file, write_file, exec_shell, grep_files, etc. + #[arg(long, value_delimiter = ',')] + allowed_tools: Option>, + /// Comma-separated list of tools to deny (deny wins over allow). + #[arg(long, value_delimiter = ',')] + disallowed_tools: Option>, + /// Maximum number of model steps (tool calls) before the run ends. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + max_turns: Option, + /// Extra text appended to the system prompt for this run. + #[arg(long)] + append_system_prompt: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -975,13 +988,23 @@ async fn main() -> Result<()> { let needs_engine = args.auto || yolo || resume_session_id.is_some() - || args.output_format == ExecOutputFormat::StreamJson; + || args.output_format == ExecOutputFormat::StreamJson + || args.max_turns.is_some() + || args.allowed_tools.is_some() + || args.disallowed_tools.is_some(); if needs_engine { let max_subagents = cli.max_subagents.map_or_else( || config.max_subagents(), |value| value.clamp(1, MAX_SUBAGENTS), ); let auto_mode = args.auto || yolo; + let max_turns = args.max_turns.unwrap_or(100); + let allowed_tools = args.allowed_tools.as_ref().map(|v| { + v.iter().map(|s| s.to_ascii_lowercase().trim().to_string()).collect::>() + }); + let disallowed_tools = args.disallowed_tools.as_ref().map(|v| { + v.iter().map(|s| s.to_ascii_lowercase().trim().to_string()).collect::>() + }); run_exec_agent( &config, &model, @@ -993,6 +1016,10 @@ async fn main() -> Result<()> { args.json, resume_session_id, args.output_format, + max_turns, + allowed_tools, + disallowed_tools, + args.append_system_prompt.clone(), ) .await } else if args.json { @@ -1249,6 +1276,10 @@ async fn run_swebench_command( false, None, args.output_format, + 100, + None, + None, + None, ) .await?; @@ -5748,6 +5779,10 @@ async fn run_exec_agent( json_output: bool, resume_session_id: Option, output_format: ExecOutputFormat, + max_turns: u32, + allowed_tools: Option>, + disallowed_tools: Option>, + append_system_prompt: Option, ) -> Result<()> { use crate::compaction::CompactionConfig; use crate::core::engine::{EngineConfig, spawn_engine}; @@ -5799,15 +5834,24 @@ async fn run_exec_agent( notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), skills_dir: config.skills_dir(), - instructions: config - .instructions_paths() - .into_iter() - .map(Into::into) - .collect(), + instructions: { + let mut instrs: Vec = config + .instructions_paths() + .into_iter() + .map(Into::into) + .collect(); + if let Some(ref extra) = append_system_prompt { + instrs.push(crate::prompts::InstructionSource::Inline { + name: "cli:append-system-prompt".into(), + content: extra.clone(), + }); + } + instrs + }, project_context_pack_enabled: config.project_context_pack_enabled(), translation_enabled: false, show_thinking: settings.show_thinking, - max_steps: 100, + max_steps: max_turns, max_subagents, features: config.features(), compaction, @@ -5837,7 +5881,8 @@ async fn run_exec_agent( vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: None, - allowed_tools: None, + allowed_tools: allowed_tools.clone(), + disallowed_tools: disallowed_tools.clone(), hook_executor: None, locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() @@ -5895,7 +5940,7 @@ async fn run_exec_agent( mode, model: effective_model.clone(), goal_objective: None, - allowed_tools: None, + allowed_tools: allowed_tools.clone(), hook_executor: None, reasoning_effort: effective_reasoning_effort, reasoning_effort_auto: auto_model, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 862d82e5..724c9d35 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2082,6 +2082,7 @@ impl RuntimeThreadManager { strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), goal_objective: None, allowed_tools: None, + disallowed_tools: None, hook_executor: None, locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3481d43e..1887dbe3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -894,6 +894,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { ), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, allowed_tools: app.active_allowed_tools.clone(), + disallowed_tools: None, hook_executor: app.runtime_services.hook_executor.clone(), network_policy: config.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())