fix(exec): wire --disallowed-tools into the gate chain (deny wins over allow), filter the advertised tool catalog, honor --append-system-prompt in needs_engine, surface max-steps notice in text mode; add clap/gate/catalog tests (#3027)

Co-Authored-By: Claude <noreply@anthropic.com>
https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5
This commit is contained in:
Claude
2026-06-11 00:31:05 +00:00
parent b433989cc3
commit c15e937096
4 changed files with 149 additions and 3 deletions
+18
View File
@@ -1813,6 +1813,11 @@ impl Engine {
tool.defer_loading = Some(false);
}
}
filter_tool_catalog_for_gates(
&mut catalog,
self.config.allowed_tools.as_deref(),
self.config.disallowed_tools.as_deref(),
);
catalog
});
let tool_catalog_for_event = tools.clone();
@@ -2786,6 +2791,19 @@ pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] {
tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS
}
/// Drop catalog entries the execution gates would reject (#3027): the model
/// should never be advertised a tool it cannot call. Deny wins over allow.
fn filter_tool_catalog_for_gates(
catalog: &mut Vec<Tool>,
allowed_tools: Option<&[String]>,
disallowed_tools: Option<&[String]>,
) {
catalog.retain(|tool| {
!turn_loop::command_denies_tool(disallowed_tools, &tool.name)
&& turn_loop::command_allows_tool(allowed_tools, &tool.name)
});
}
use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision};
#[cfg(test)]
use self::dispatch::should_parallelize_tool_batch;
+39
View File
@@ -85,6 +85,45 @@ fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine {
engine
}
fn catalog_tool(name: &str) -> Tool {
Tool {
tool_type: None,
name: name.to_string(),
description: String::new(),
input_schema: json!({"type": "object"}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
}
}
#[test]
fn tool_catalog_filter_applies_allow_and_deny_gates() {
// #3027 AC1: the advertised catalog must not contain tools the execution
// gates would deny; deny wins over allow.
let mut catalog = vec![
catalog_tool("read_file"),
catalog_tool("exec_shell"),
catalog_tool("grep_files"),
];
filter_tool_catalog_for_gates(
&mut catalog,
Some(&["read_file".to_string(), "exec_shell".to_string()][..]),
Some(&["exec_shell".to_string()][..]),
);
let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["read_file"]);
}
#[test]
fn tool_catalog_filter_is_inert_without_gates() {
let mut catalog = vec![catalog_tool("read_file"), catalog_tool("exec_shell")];
filter_tool_catalog_for_gates(&mut catalog, None, None);
assert_eq!(catalog.len(), 2);
}
#[test]
fn structured_state_block_includes_rich_plan_artifact() {
let state = StructuredState {
+42 -2
View File
@@ -1380,6 +1380,16 @@ impl Engine {
)));
}
// #3027: deny wins over allow — check the deny-list first so a
// tool present in both lists is still blocked.
if blocked_error.is_none()
&& command_denies_tool(self.config.disallowed_tools.as_deref(), &tool_name)
{
blocked_error = Some(ToolError::permission_denied(format!(
"Tool '{tool_name}' is in the disallowed-tools list"
)));
}
if blocked_error.is_none()
&& !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name)
{
@@ -2371,7 +2381,7 @@ mod stream_timeout_tests {
}
}
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
pub(super) fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
let Some(allowed_tools) = allowed_tools else {
return true;
};
@@ -2380,7 +2390,7 @@ fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> boo
/// 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 {
pub(super) fn command_denies_tool(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool {
let Some(disallowed_tools) = disallowed_tools else {
return false;
};
@@ -2722,6 +2732,36 @@ mod tests {
assert!(!command_allows_tool(Some(&allowed), "bash"));
}
#[test]
fn disallowed_tools_gate_blocks_listed_tool() {
let disallowed = vec!["exec_shell".to_string()];
assert!(command_denies_tool(Some(&disallowed), "exec_shell"));
assert!(!command_denies_tool(Some(&disallowed), "read_file"));
}
#[test]
fn disallowed_tools_gate_blocks_case_insensitively() {
let disallowed = vec!["exec_shell".to_string()];
assert!(command_denies_tool(Some(&disallowed), "Exec_Shell"));
}
#[test]
fn disallowed_tools_gate_is_inert_when_not_set() {
assert!(!command_denies_tool(None, "exec_shell"));
let empty: Vec<String> = Vec::new();
assert!(!command_denies_tool(Some(&empty), "exec_shell"));
}
#[test]
fn deny_wins_over_allow_for_same_tool() {
// The turn-loop gate chain checks the deny-list before the allow-list,
// so a tool present in both must still be blocked.
let allowed = vec!["exec_shell".to_string()];
let disallowed = vec!["exec_shell".to_string()];
assert!(command_allows_tool(Some(&allowed), "exec_shell"));
assert!(command_denies_tool(Some(&disallowed), "exec_shell"));
}
#[test]
fn review_regression_allowed_tools_gate_checks_canonical_tool_name() {
let tmp = tempfile::tempdir().expect("tempdir");
+50 -1
View File
@@ -991,7 +991,8 @@ async fn main() -> Result<()> {
|| args.output_format == ExecOutputFormat::StreamJson
|| args.max_turns.is_some()
|| args.allowed_tools.is_some()
|| args.disallowed_tools.is_some();
|| args.disallowed_tools.is_some()
|| args.append_system_prompt.is_some();
if needs_engine {
let max_subagents = cli.max_subagents.map_or_else(
|| config.max_subagents(),
@@ -6233,6 +6234,15 @@ async fn run_exec_agent(
latest_model = model;
latest_workspace = workspace;
}
// #3027: surface the engine's max-steps notice in text mode so a
// --max-turns run that stops early says why instead of going quiet.
Event::Status { message }
if output_format == ExecOutputFormat::Text
&& !json_output
&& message.contains("Reached maximum steps") =>
{
eprintln!("{message}");
}
_ => {}
}
}
@@ -6693,6 +6703,45 @@ mod terminal_mode_tests {
assert_eq!(args.output_format, ExecOutputFormat::Text);
}
#[test]
fn exec_parses_tool_gate_and_hardening_flags() {
let cli = parse_cli(&[
"codewhale",
"exec",
"--allowed-tools",
"read_file,grep_files",
"--disallowed-tools",
"exec_shell",
"--max-turns",
"7",
"--append-system-prompt",
"extra rules",
"do the thing",
]);
let Some(Commands::Exec(args)) = cli.command else {
panic!("expected exec command");
};
assert_eq!(
args.allowed_tools.as_deref(),
Some(&["read_file".to_string(), "grep_files".to_string()][..])
);
assert_eq!(
args.disallowed_tools.as_deref(),
Some(&["exec_shell".to_string()][..])
);
assert_eq!(args.max_turns, Some(7));
assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
assert_eq!(args.prompt, vec!["do the thing"]);
}
#[test]
fn exec_rejects_zero_max_turns() {
let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
.expect_err("max-turns must be >= 1");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn exec_accepts_continue_for_latest_workspace_session() {
let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);