feat(exec): add --allowed-tools, --disallowed-tools, --max-turns, --append-system-prompt (#3027)

Headless exec hardening for benchmark/CI/droplet use:
- New CLI flags: --allowed-tools, --disallowed-tools, --max-turns, --append-system-prompt
- Add disallowed_tools to EngineConfig + command_denies_tool() helper
- run_exec_agent threads all four flags into EngineConfig and Op::SendMessage
- needs_engine now includes flag presence for standalone exec use
This commit is contained in:
Hunter Bown
2026-06-10 16:17:33 -07:00
parent b23067bacd
commit dbd9b9670d
5 changed files with 69 additions and 9 deletions
+4
View File
@@ -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<Vec<String>>,
/// Tool deny-list. Deny always wins over allow (#3027).
/// `None` means no tools are explicitly denied.
pub disallowed_tools: Option<Vec<String>>,
/// Hook executor for control-plane hooks.
/// `ToolCallBefore` hooks may deny a tool call with exit code 2.
pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
@@ -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,
+9
View File
@@ -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],
+54 -9
View File
@@ -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<Vec<String>>,
/// Comma-separated list of tools to deny (deny wins over allow).
#[arg(long, value_delimiter = ',')]
disallowed_tools: Option<Vec<String>>,
/// Maximum number of model steps (tool calls) before the run ends.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
max_turns: Option<u32>,
/// Extra text appended to the system prompt for this run.
#[arg(long)]
append_system_prompt: Option<String>,
}
#[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::<Vec<_>>()
});
let disallowed_tools = args.disallowed_tools.as_ref().map(|v| {
v.iter().map(|s| s.to_ascii_lowercase().trim().to_string()).collect::<Vec<_>>()
});
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<String>,
output_format: ExecOutputFormat,
max_turns: u32,
allowed_tools: Option<Vec<String>>,
disallowed_tools: Option<Vec<String>>,
append_system_prompt: Option<String>,
) -> 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<crate::prompts::InstructionSource> = 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,
+1
View File
@@ -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()
+1
View File
@@ -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())