fix: type allowed tools validation

This commit is contained in:
bellman
2026-06-04 12:01:58 +09:00
parent 22fdaeae2c
commit ecd3e4ceb9
6 changed files with 400 additions and 57 deletions

View File

@@ -63,7 +63,8 @@ use runtime::{
use serde::Deserialize;
use serde_json::{json, Map, Value};
use tools::{
execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
RuntimeToolDefinition, ToolSearchOutput,
};
const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-7";
@@ -356,6 +357,19 @@ fn main() {
);
}
}
} else if kind == "invalid_tool_name" {
let (tool_name, available, aliases) = invalid_tool_name_details(&message);
if let Some(object) = error_json.as_object_mut() {
if let Some(tool_name) = tool_name {
object.insert("tool_name".to_string(), serde_json::json!(tool_name));
}
object.insert("available".to_string(), serde_json::json!(available));
object.insert("tool_aliases".to_string(), aliases);
}
} else if kind == "missing_argument" && message.contains("--allowedTools") {
if let Some(object) = error_json.as_object_mut() {
object.insert("argument".to_string(), serde_json::json!("--allowedTools"));
}
}
// #819/#820/#823: JSON mode error envelopes must go to stdout so machine
// consumers can parse failures from stdout byte 0 (parity with all
@@ -428,6 +442,8 @@ fn classify_error_kind(message: &str) -> &'static str {
"invalid_cwd"
} else if message.starts_with("invalid_output_path:") {
"invalid_output_path"
} else if message.starts_with("invalid_tool_name:") {
"invalid_tool_name"
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
"cli_parse"
} else if message.starts_with("missing_flag_value:") {
@@ -534,6 +550,42 @@ fn split_error_hint(message: &str) -> (String, Option<String>) {
}
}
fn invalid_tool_name_details(message: &str) -> (Option<String>, Vec<String>, Value) {
let tool_name = message
.strip_prefix("invalid_tool_name: unsupported tool in --allowedTools:")
.and_then(|rest| rest.lines().next())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let available = message
.lines()
.find_map(|line| line.strip_prefix("Available:"))
.map(|line| {
line.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let aliases = message
.lines()
.find_map(|line| line.strip_prefix("Aliases:"))
.map(|line| {
line.split(',')
.filter_map(|entry| entry.trim().split_once('='))
.map(|(alias, canonical)| {
(
alias.trim().to_string(),
Value::String(canonical.trim().to_string()),
)
})
.collect::<Map<_, _>>()
})
.unwrap_or_default();
(tool_name, available, Value::Object(aliases))
}
/// #781: derive a stable fallback hint from a classified error kind when the error
/// message itself has no `\n`-delimited hint. Returns `None` for kinds where the
/// message is self-explanatory or no canonical remediation exists.
@@ -576,6 +628,9 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
"invalid_install_source" => Some(
"Pass a local skill directory containing SKILL.md or a standalone markdown file.",
),
"invalid_tool_name" => Some(
"Use canonical snake_case tool names from `available` or documented aliases from `tool_aliases`.",
),
_ => None,
}
}
@@ -1404,16 +1459,27 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--allowedTools" | "--allowed-tools" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --allowedTools.\nUsage: --allowedTools <tool-name> e.g. --allowedTools Bash".to_string())?;
.ok_or_else(allowed_tools_missing_error)?;
if value.starts_with('-') || is_known_top_level_subcommand(value) {
return Err(allowed_tools_missing_error());
}
allowed_tool_values.push(value.clone());
index += 2;
}
flag if flag.starts_with("--allowedTools=") => {
allowed_tool_values.push(flag[15..].to_string());
let value = flag[15..].to_string();
if value.trim().is_empty() {
return Err(allowed_tools_missing_error());
}
allowed_tool_values.push(value);
index += 1;
}
flag if flag.starts_with("--allowed-tools=") => {
allowed_tool_values.push(flag[16..].to_string());
let value = flag[16..].to_string();
if value.trim().is_empty() {
return Err(allowed_tools_missing_error());
}
allowed_tool_values.push(value);
index += 1;
}
other if rest.is_empty() && other.starts_with('-') => {
@@ -2391,6 +2457,41 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
(!suggestions.is_empty()).then_some(suggestions)
}
fn is_known_top_level_subcommand(value: &str) -> bool {
matches!(
value,
"help"
| "version"
| "status"
| "sandbox"
| "doctor"
| "state"
| "dump-manifests"
| "bootstrap-plan"
| "agents"
| "agent"
| "mcp"
| "skills"
| "skill"
| "plugins"
| "plugin"
| "marketplace"
| "system-prompt"
| "acp"
| "init"
| "export"
| "prompt"
| "resume"
| "session"
| "compact"
| "config"
| "model"
| "models"
| "settings"
| "diff"
)
}
fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
@@ -2549,6 +2650,20 @@ fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>,
current_tool_registry()?.normalize_allowed_tools(values)
}
fn allowed_tools_missing_error() -> String {
"missing_argument: --allowedTools requires a tool list before subcommands or flags.\nUsage: --allowedTools <tool-name>[,<tool-name>...] e.g. --allowedTools read,glob".to_string()
}
fn allowed_tool_aliases_json(registry: &GlobalToolRegistry) -> Value {
Value::Object(
registry
.allowed_tool_aliases()
.into_iter()
.map(|(alias, canonical)| (alias, Value::String(canonical)))
.collect(),
)
}
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
let cwd = env::current_dir().map_err(|error| error.to_string())?;
let loader = ConfigLoader::default_for(&cwd);
@@ -3089,6 +3204,7 @@ impl DoctorReport {
fn json_value(&self) -> Value {
let report = self.render();
let (ok_count, warn_count, fail_count) = self.counts();
let tool_registry = GlobalToolRegistry::builtin();
json!({
"kind": "doctor",
"action": "doctor",
@@ -3107,6 +3223,10 @@ impl DoctorReport {
.iter()
.map(DiagnosticCheck::json_value)
.collect::<Vec<_>>(),
"allowed_tools": {
"available": tool_registry.canonical_allowed_tool_names(),
"aliases": allowed_tool_aliases_json(&tool_registry),
},
})
}
}
@@ -8194,6 +8314,9 @@ fn status_json_value(
let model_env_var = provenance.and_then(|p| p.env_var.clone());
let permission_mode_source = permission_provenance.map(|p| p.source.as_str());
let permission_mode_env_var = permission_provenance.and_then(|p| p.env_var);
let tool_registry = GlobalToolRegistry::builtin();
let available_tool_names = tool_registry.canonical_allowed_tool_names();
let tool_aliases = allowed_tool_aliases_json(&tool_registry);
// #732: always emit an array (empty when unrestricted) so callers can do
// `.allowed_tools.entries | length > 0` without a null-check first.
let allowed_tool_entries = allowed_tools
@@ -8217,6 +8340,8 @@ fn status_json_value(
"source": if allowed_tools.is_some() { "flag" } else { "default" },
"restricted": allowed_tools.is_some(),
"entries": allowed_tool_entries,
"available": available_tool_names,
"aliases": tool_aliases,
},
"binary_provenance": context.binary_provenance.json_value(),
"usage": {
@@ -8919,7 +9044,7 @@ fn render_doctor_help_json() -> serde_json::Value {
"requires_provider_request": false,
"requires_session_resume": false,
"mutates_workspace": false,
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks"],
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"],
"check_names": ["auth", "config", "install source", "workspace", "boot preflight", "sandbox", "permissions", "system"],
"status_values": ["ok", "warn", "fail"],
"options": [
@@ -12217,7 +12342,7 @@ impl ToolExecutor for CliToolExecutor {
if self
.allowed_tools
.as_ref()
.is_some_and(|allowed| !allowed.contains(tool_name))
.is_some_and(|allowed| !allowed.contains(&canonical_allowed_tool_name(tool_name)))
{
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
@@ -12422,7 +12547,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
out,
" --dangerously-skip-permissions, --skip-permissions Skip all permission checks"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
out,
" --allowedTools TOOLS Restrict enabled tools by canonical snake_case name or alias"
)?;
writeln!(out, " Examples: read, glob, web_fetch, WebFetch; status JSON exposes aliases")?;
writeln!(
out,
" --version, -V Print version and build information locally"
@@ -13346,13 +13475,41 @@ mod tests {
);
}
#[test]
fn rejects_allowed_tools_followed_by_subcommand_or_flag_432() {
let _env_guard = env_lock();
let _cwd_guard = cwd_guard();
for args in [
vec!["--allowedTools".to_string(), "status".to_string()],
vec![
"--allowedTools".to_string(),
"status".to_string(),
"--output-format".to_string(),
"json".to_string(),
],
vec!["--allowedTools".to_string(), "--output-format".to_string()],
vec!["--allowedTools=".to_string()],
] {
let error = parse_args(&args).expect_err("allowedTools missing value should reject");
assert!(
error.starts_with("missing_argument: --allowedTools requires a tool list"),
"unexpected error for {args:?}: {error}"
);
}
}
#[test]
fn rejects_unknown_allowed_tools() {
let _env_guard = env_lock();
let _cwd_guard = cwd_guard();
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
.expect_err("tool should be rejected");
assert!(error.starts_with("invalid_tool_name:"));
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
assert!(error.contains("Available: "));
assert!(error.contains("web_fetch"));
assert!(error.contains("Aliases: "));
assert!(error.contains("WebFetch=web_fetch"));
}
#[test]
@@ -14097,6 +14254,18 @@ mod tests {
Some(false),
"default status should expose unrestricted tool state: {json}"
);
assert_eq!(
json.pointer("/allowed_tools/available/0")
.and_then(|v| v.as_str()),
Some("agent"),
"status JSON should expose canonical snake_case available tools: {json}"
);
assert_eq!(
json.pointer("/allowed_tools/aliases/WebFetch")
.and_then(|v| v.as_str()),
Some("web_fetch"),
"status JSON should expose allowed-tool aliases: {json}"
);
let allowed: super::AllowedToolSet = ["read_file", "grep_search"]
.into_iter()
@@ -14417,6 +14586,10 @@ mod tests {
classify_error_kind("invalid_install_source: bogus"),
"invalid_install_source"
);
assert_eq!(
classify_error_kind("invalid_tool_name: unsupported tool in --allowedTools: teleport"),
"invalid_tool_name"
);
assert_eq!(
classify_error_kind(
"missing_flag_value: missing value for --model.\nUsage: --model <provider/model>"
@@ -17206,7 +17379,7 @@ UU conflicted.rs",
.expect("mcp tools should be allow-listable")
.expect("allow-list should exist");
assert!(allowed.contains("mcp__alpha__echo"));
assert!(allowed.contains("MCPTool"));
assert!(allowed.contains("mcp_tool"));
let mut executor = CliToolExecutor::new(
None,

View File

@@ -1252,9 +1252,13 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(summary["ok"].as_u64().is_some());
assert!(summary["warnings"].as_u64().is_some());
assert!(summary["failures"].as_u64().is_some());
assert_eq!(doctor["allowed_tools"]["aliases"]["WebFetch"], "web_fetch");
assert!(doctor["allowed_tools"]["available"]
.as_array()
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 7);
assert_eq!(checks.len(), 8);
let check_names = checks
.iter()
.map(|check| {
@@ -1279,6 +1283,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
"workspace",
"boot preflight",
"sandbox",
"permissions",
"system"
]
);
@@ -2718,6 +2723,52 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
);
}
#[test]
fn allowed_tools_errors_have_typed_json_and_alias_map_432() {
let root = unique_temp_dir("allowed-tools-432");
fs::create_dir_all(&root).expect("temp dir");
let missing = run_claw(
&root,
&["--allowedTools", "status", "--output-format", "json"],
&[],
);
assert_eq!(missing.status.code(), Some(1));
assert!(
missing.stderr.is_empty(),
"JSON missing allowedTools value must keep stderr empty: {}",
String::from_utf8_lossy(&missing.stderr)
);
let missing_json = parse_json_stdout(&missing, "allowedTools subcommand missing value");
assert_eq!(missing_json["error_kind"], "missing_argument");
assert_eq!(missing_json["argument"], "--allowedTools");
assert!(missing_json["hint"]
.as_str()
.is_some_and(|hint| { hint.contains("--allowedTools") && hint.contains("read,glob") }));
let invalid = run_claw(
&root,
&["--output-format", "json", "--allowedTools", "teleport"],
&[],
);
assert_eq!(invalid.status.code(), Some(1));
assert!(
invalid.stderr.is_empty(),
"JSON invalid allowedTools value must keep stderr empty: {}",
String::from_utf8_lossy(&invalid.stderr)
);
let invalid_json = parse_json_stdout(&invalid, "allowedTools invalid tool");
assert_eq!(invalid_json["error_kind"], "invalid_tool_name");
assert_eq!(invalid_json["tool_name"], "teleport");
assert!(invalid_json["available"]
.as_array()
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
assert_eq!(invalid_json["tool_aliases"]["WebFetch"], "web_fetch");
assert!(invalid_json["hint"]
.as_str()
.is_some_and(|hint| { hint.contains("canonical snake_case") && hint.contains("aliases") }));
}
#[test]
fn short_p_flag_swallows_no_flags_755() {
// #755: `claw -p hello --output-format json` must parse --output-format json

View File

@@ -201,30 +201,20 @@ impl GlobalToolRegistry {
return Ok(None);
}
let builtin_specs = mvp_tool_specs();
let canonical_names = builtin_specs
.iter()
.map(|spec| spec.name.to_string())
.chain(
self.plugin_tools
.iter()
.map(|tool| tool.definition().name.clone()),
)
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
let actual_names = self.actual_tool_names();
let canonical_names = self.canonical_allowed_tool_names();
let canonical_name_set = canonical_names.iter().cloned().collect::<BTreeSet<_>>();
let mut name_map = BTreeMap::new();
for actual in &actual_names {
let canonical = canonical_allowed_tool_name(actual);
name_map.insert(allowed_tool_lookup_key(actual), canonical.clone());
name_map.insert(allowed_tool_lookup_key(&canonical), canonical);
}
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());
for (alias, canonical) in self.allowed_tool_aliases() {
if canonical_name_set.contains(&canonical) {
name_map.insert(allowed_tool_lookup_key(&alias), canonical);
}
}
let mut allowed = BTreeSet::new();
@@ -233,11 +223,11 @@ impl GlobalToolRegistry {
.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(|| {
let canonical = name_map.get(&allowed_tool_lookup_key(token)).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
"invalid_tool_name: unsupported tool in --allowedTools: {token}\nAvailable: {}\nAliases: {}\nHint: Use canonical snake_case tool names from Available or aliases from Aliases.",
canonical_names.join(", "),
format_allowed_tool_aliases(&self.allowed_tool_aliases())
)
})?;
allowed.insert(canonical.clone());
@@ -258,7 +248,10 @@ impl GlobalToolRegistry {
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
let builtin = mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
@@ -267,7 +260,11 @@ impl GlobalToolRegistry {
let runtime = self
.runtime_tools
.iter()
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
.filter(|tool| {
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(&tool.name))
})
})
.map(|tool| ToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
@@ -277,8 +274,11 @@ impl GlobalToolRegistry {
.plugin_tools
.iter()
.filter(|tool| {
allowed_tools
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(
tool.definition().name.as_str(),
))
})
})
.map(|tool| ToolDefinition {
name: tool.definition().name.clone(),
@@ -294,19 +294,29 @@ impl GlobalToolRegistry {
) -> Result<Vec<(String, PermissionMode)>, String> {
let builtin = mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.map(|spec| (spec.name.to_string(), spec.required_permission));
let runtime = self
.runtime_tools
.iter()
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
.filter(|tool| {
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(&tool.name))
})
})
.map(|tool| (tool.name.clone(), tool.required_permission));
let plugin = self
.plugin_tools
.iter()
.filter(|tool| {
allowed_tools
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
allowed_tools.is_none_or(|allowed| {
allowed.contains(&canonical_allowed_tool_name(
tool.definition().name.as_str(),
))
})
})
.map(|tool| {
permission_mode_from_plugin(tool.required_permission())
@@ -316,6 +326,52 @@ impl GlobalToolRegistry {
Ok(builtin.chain(runtime).chain(plugin).collect())
}
#[must_use]
pub fn actual_tool_names(&self) -> Vec<String> {
mvp_tool_specs()
.iter()
.map(|spec| spec.name.to_string())
.chain(
self.plugin_tools
.iter()
.map(|tool| tool.definition().name.clone()),
)
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
.collect()
}
#[must_use]
pub fn canonical_allowed_tool_names(&self) -> Vec<String> {
self.actual_tool_names()
.into_iter()
.map(|name| canonical_allowed_tool_name(&name))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[must_use]
pub fn allowed_tool_aliases(&self) -> BTreeMap<String, String> {
let mut aliases = BTreeMap::from([
("read".to_string(), "read_file".to_string()),
("Read".to_string(), "read_file".to_string()),
("write".to_string(), "write_file".to_string()),
("Write".to_string(), "write_file".to_string()),
("edit".to_string(), "edit_file".to_string()),
("Edit".to_string(), "edit_file".to_string()),
("glob".to_string(), "glob_search".to_string()),
("Glob".to_string(), "glob_search".to_string()),
("grep".to_string(), "grep_search".to_string()),
("Grep".to_string(), "grep_search".to_string()),
]);
for actual in self.actual_tool_names() {
let canonical = canonical_allowed_tool_name(&actual);
if actual != canonical {
aliases.insert(actual, canonical);
}
}
aliases
}
#[must_use]
pub fn has_runtime_tool(&self, name: &str) -> bool {
self.runtime_tools.iter().any(|tool| tool.name == name)
@@ -378,8 +434,40 @@ impl GlobalToolRegistry {
}
}
fn normalize_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase()
pub fn canonical_allowed_tool_name(value: &str) -> String {
let trimmed = value.trim().replace('-', "_");
let mut output = String::new();
let chars = trimmed.chars().collect::<Vec<_>>();
for (index, ch) in chars.iter().copied().enumerate() {
if ch == '_' || ch.is_whitespace() {
output.push('_');
continue;
}
let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
let next = chars.get(index + 1).copied();
if ch.is_ascii_uppercase()
&& index > 0
&& !output.ends_with('_')
&& (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
|| next.is_some_and(|n| n.is_ascii_lowercase()))
{
output.push('_');
}
output.push(ch.to_ascii_lowercase());
}
output.trim_matches('_').to_string()
}
fn allowed_tool_lookup_key(value: &str) -> String {
canonical_allowed_tool_name(value).replace('_', "")
}
fn format_allowed_tool_aliases(aliases: &BTreeMap<String, String>) -> String {
aliases
.iter()
.map(|(alias, canonical)| format!("{alias}={canonical}"))
.collect::<Vec<_>>()
.join(", ")
}
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
@@ -4210,7 +4298,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"PowerShell",
],
};
tools.into_iter().map(str::to_string).collect()
tools.into_iter().map(canonical_allowed_tool_name).collect()
}
fn agent_permission_policy() -> PermissionPolicy {
@@ -5238,7 +5326,10 @@ impl SubagentToolExecutor {
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if !self.allowed_tools.contains(tool_name) {
if !self
.allowed_tools
.contains(&canonical_allowed_tool_name(tool_name))
{
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
@@ -5253,7 +5344,10 @@ impl ToolExecutor for SubagentToolExecutor {
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.filter(|spec| {
allowed_tools
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
})
.collect()
}
@@ -7603,6 +7697,29 @@ mod tests {
}
}
#[test]
fn allowed_tools_normalize_to_canonical_snake_case_and_aliases_432() {
let registry = GlobalToolRegistry::builtin();
let allowed = registry
.normalize_allowed_tools(&["Read,WebFetch,MCP".to_string()])
.expect("aliases and legacy names should normalize")
.expect("allow-list should be populated");
assert!(allowed.contains("read_file"));
assert!(allowed.contains("web_fetch"));
assert!(allowed.contains("mcp"));
assert!(!allowed.contains("Read"));
assert!(!allowed.contains("WebFetch"));
let canonical = registry.canonical_allowed_tool_names();
assert!(canonical.contains(&"web_fetch".to_string()));
assert!(canonical.contains(&"todo_write".to_string()));
assert!(!canonical.contains(&"WebFetch".to_string()));
assert_eq!(
registry.allowed_tool_aliases().get("WebFetch"),
Some(&"web_fetch".to_string())
);
}
#[test]
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
let registry = GlobalToolRegistry::builtin()
@@ -8584,7 +8701,7 @@ mod tests {
.expect("spawn job should be captured");
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
assert!(captured_job.allowed_tools.contains("read_file"));
assert!(!captured_job.allowed_tools.contains("Agent"));
assert!(!captured_job.allowed_tools.contains("agent"));
let normalized = execute_tool(
"Agent",
@@ -9184,7 +9301,7 @@ mod tests {
let general = allowed_tools_for_subagent("general-purpose");
assert!(general.contains("bash"));
assert!(general.contains("write_file"));
assert!(!general.contains("Agent"));
assert!(!general.contains("agent"));
let explore = allowed_tools_for_subagent("Explore");
assert!(explore.contains("read_file"));
@@ -9192,13 +9309,13 @@ mod tests {
assert!(!explore.contains("bash"));
let plan = allowed_tools_for_subagent("Plan");
assert!(plan.contains("TodoWrite"));
assert!(plan.contains("StructuredOutput"));
assert!(!plan.contains("Agent"));
assert!(plan.contains("todo_write"));
assert!(plan.contains("structured_output"));
assert!(!plan.contains("agent"));
let verification = allowed_tools_for_subagent("Verification");
assert!(verification.contains("bash"));
assert!(verification.contains("PowerShell"));
assert!(verification.contains("power_shell"));
assert!(!verification.contains("write_file"));
}