Merge pull request #2663 from zeroclaw-labs/issue-2651-agent-allowed-denied-tools

feat(agent): add primary allowed_tools/denied_tools filtering
This commit is contained in:
Argenis 2026-03-03 18:15:23 -05:00 committed by GitHub
commit 403fd2dc2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 510 additions and 0 deletions

View File

@ -129,6 +129,8 @@ Operational note for container users:
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
| `tool_dispatcher` | `auto` | Tool dispatch strategy |
| `allowed_tools` | `[]` | Primary-agent tool allowlist. When non-empty, only listed tools are exposed in context |
| `denied_tools` | `[]` | Primary-agent tool denylist applied after `allowed_tools` |
| `loop_detection_no_progress_threshold` | `3` | Same tool+args producing identical output this many times triggers loop detection. `0` disables |
| `loop_detection_ping_pong_cycles` | `2` | A→B→A→B alternating pattern cycle count threshold. `0` disables |
| `loop_detection_failure_streak` | `3` | Same tool consecutive failure count threshold. `0` disables |
@ -139,8 +141,27 @@ Notes:
- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations (<value>)`.
- In CLI, gateway, and channel tool loops, multiple independent tool calls are executed concurrently by default when the pending calls do not require approval gating; result order remains stable.
- `parallel_tools` applies to the `Agent::turn()` API surface. It does not gate the runtime loop used by CLI, gateway, or channel handlers.
- `allowed_tools` / `denied_tools` are applied at startup before prompt construction. Excluded tools are omitted from system prompt context and tool specs.
- Unknown entries in `allowed_tools` are skipped and logged at debug level.
- If both `allowed_tools` and `denied_tools` are configured and the denylist removes all allowlisted matches, startup fails fast with a clear config error.
- **Loop detection** intervenes before `max_tool_iterations` is exhausted. On first detection the agent receives a self-correction prompt; if the loop persists the agent is stopped early. Detection is result-aware: repeated calls with *different* outputs (genuine progress) do not trigger. Set any threshold to `0` to disable that detector.
Example:
```toml
[agent]
allowed_tools = [
"delegate",
"subagent_spawn",
"subagent_list",
"subagent_manage",
"memory_recall",
"memory_store",
"task_plan",
]
denied_tools = ["shell", "file_write", "browser_open"]
```
## `[agent.teams]`
Controls synchronous team delegation behavior (`delegate` tool).

View File

@ -68,4 +68,13 @@ allowed_users = ["το-όνομά-σας"] # Ποιοι επιτρέπεται
- Αν αλλάξετε το αρχείο `config.toml`, πρέπει να κάνετε επανεκκίνηση το ZeroClaw για να δει τις αλλαγές.
- Χρησιμοποιήστε την εντολή `zeroclaw doctor` για να βεβαιωθείτε ότι οι ρυθμίσεις σας είναι σωστές.
## Ενημέρωση (2026-03-03)
- Στην ενότητα `[agent]` προστέθηκαν τα `allowed_tools` και `denied_tools`.
- Αν το `allowed_tools` δεν είναι κενό, ο primary agent βλέπει μόνο τα εργαλεία της λίστας.
- Το `denied_tools` εφαρμόζεται μετά το allowlist και αφαιρεί επιπλέον εργαλεία.
- Άγνωστες τιμές στο `allowed_tools` αγνοούνται (με debug log) και δεν μπλοκάρουν την εκκίνηση.
- Αν `allowed_tools` και `denied_tools` καταλήξουν να αφαιρέσουν όλα τα εκτελέσιμα εργαλεία, η εκκίνηση αποτυγχάνει άμεσα με σαφές μήνυμα ρύθμισης.
- Για πλήρη πίνακα πεδίων και παράδειγμα, δείτε το αγγλικό `config-reference.md` στην ενότητα `[agent]`.
- Μην μοιράζεστε ποτέ το αρχείο `config.toml` με άλλους, καθώς περιέχει τα μυστικά κλειδιά σας (tokens).

View File

@ -21,3 +21,8 @@ Source anglaise:
- Ajout de `provider.reasoning_level` (OpenAI Codex `/responses`). Voir la source anglaise pour les détails.
- Valeur par défaut de `agent.max_tool_iterations` augmentée à `20` (fallback sûr si `0`).
- Ajout de `agent.allowed_tools` et `agent.denied_tools` pour filtrer les outils visibles par l'agent principal.
- `allowed_tools` non vide: seuls les outils listés sont exposés.
- `denied_tools`: retrait supplémentaire appliqué après `allowed_tools`.
- Les entrées inconnues dans `allowed_tools` sont ignorées (log debug), sans échec de démarrage.
- Si `allowed_tools` + `denied_tools` suppriment tous les outils exécutables, le démarrage échoue immédiatement avec une erreur de configuration claire.

View File

@ -16,3 +16,12 @@
- 設定キー名は英語のまま保持します。
- 実行時挙動の定義は英語版原文を優先します。
## 更新ート2026-03-03
- `[agent]``allowed_tools` / `denied_tools` が追加されました。
- `allowed_tools` が空でない場合、メインエージェントには許可リストのツールのみ公開されます。
- `denied_tools` は許可リスト適用後に追加でツールを除外します。
- `allowed_tools` の未一致エントリは起動失敗にせず、debug ログのみ出力されます。
- `allowed_tools``denied_tools` の組み合わせで実行可能ツールが 0 件になる場合は、明確な設定エラーで fail-fast します。
- 詳細な表と例は英語版 `config-reference.md``[agent]` セクションを参照してください。

View File

@ -16,3 +16,12 @@
- Названия config keys не переводятся.
- Точное runtime-поведение определяется английским оригиналом.
## Обновление (2026-03-03)
- В секции `[agent]` добавлены `allowed_tools` и `denied_tools`.
- Если `allowed_tools` не пуст, основному агенту показываются только инструменты из allowlist.
- `denied_tools` применяется после allowlist и дополнительно исключает инструменты.
- Неизвестные элементы `allowed_tools` пропускаются (с debug-логом) и не ломают запуск.
- Если одновременно заданы `allowed_tools` и `denied_tools`, и после фильтрации не остается исполняемых инструментов, запуск завершается fail-fast с явной ошибкой конфигурации.
- Полная таблица параметров и пример остаются в английском `config-reference.md` в разделе `[agent]`.

View File

@ -81,6 +81,8 @@ Lưu ý cho người dùng container:
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
| `allowed_tools` | `[]` | Allowlist tool cho agent chính. Khi không rỗng, chỉ các tool liệt kê mới được đưa vào context |
| `denied_tools` | `[]` | Denylist tool cho agent chính, áp dụng sau `allowed_tools` |
Lưu ý:
@ -88,6 +90,25 @@ Lưu ý:
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
- `allowed_tools` / `denied_tools` được áp dụng lúc khởi động trước khi dựng prompt. Tool bị loại sẽ không xuất hiện trong system prompt hoặc tool specs.
- Mục không khớp trong `allowed_tools` được bỏ qua (không làm lỗi khởi động) và ghi log mức debug.
- Nếu đồng thời đặt `allowed_tools``denied_tools` rồi denylist loại toàn bộ tool đã allow, tiến trình sẽ fail-fast với lỗi cấu hình rõ ràng.
Ví dụ:
```toml
[agent]
allowed_tools = [
"delegate",
"subagent_spawn",
"subagent_list",
"subagent_manage",
"memory_recall",
"memory_store",
"task_plan",
]
denied_tools = ["shell", "file_write", "browser_open"]
```
## `[agents.<name>]`

View File

@ -16,3 +16,12 @@
- 配置键保持英文,避免本地化改写键名。
- 生产行为以英文原文定义为准。
## 更新说明2026-03-03
- `[agent]` 新增 `allowed_tools``denied_tools`
- `allowed_tools` 非空时,只向主代理暴露白名单工具。
- `denied_tools` 在白名单过滤后继续移除工具。
- 未匹配的 `allowed_tools` 项会被跳过(调试日志提示),不会导致启动失败。
- 若同时配置 `allowed_tools``denied_tools` 且最终将可执行工具全部移除,启动会快速失败并给出明确错误。
- 详细字段表与示例见英文原文 `config-reference.md``[agent]` 小节。

View File

@ -303,6 +303,36 @@ impl Agent {
config.api_key.as_deref(),
config,
);
let (tools, tool_filter_report) = tools::filter_primary_agent_tools(
tools,
&config.agent.allowed_tools,
&config.agent.denied_tools,
);
for unmatched in tool_filter_report.unmatched_allowed_tools {
tracing::debug!(
tool = %unmatched,
"agent.allowed_tools entry did not match any registered tool"
);
}
let has_agent_allowlist = config
.agent
.allowed_tools
.iter()
.any(|entry| !entry.trim().is_empty());
let has_agent_denylist = config
.agent
.denied_tools
.iter()
.any(|entry| !entry.trim().is_empty());
if has_agent_allowlist
&& has_agent_denylist
&& tool_filter_report.allowlist_match_count > 0
&& tools.is_empty()
{
anyhow::bail!(
"agent.allowed_tools and agent.denied_tools removed all executable tools; update [agent] tool filters"
);
}
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
@ -1032,6 +1062,7 @@ mod tests {
#[test]
fn from_config_loads_plugin_declared_tools() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let tmp = TempDir::new().expect("temp dir");
let plugin_dir = tmp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).expect("create plugin dir");
@ -1069,4 +1100,77 @@ description = "plugin tool exposed for from_config tests"
.iter()
.any(|tool| tool.name() == "__agent_from_config_plugin_tool"));
}
fn base_from_config_for_tool_filter_tests() -> Config {
let root = std::env::temp_dir().join(format!(
"zeroclaw_agent_tool_filter_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(root.join("workspace")).expect("create workspace dir");
let mut config = Config::default();
config.workspace_dir = root.join("workspace");
config.config_path = root.join("config.toml");
config.default_provider = Some("ollama".to_string());
config.memory.backend = "none".to_string();
config
}
#[test]
fn from_config_primary_allowlist_filters_tools() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let mut config = base_from_config_for_tool_filter_tests();
config.agent.allowed_tools = vec!["shell".to_string()];
let agent = Agent::from_config(&config).expect("agent should build");
let names: Vec<&str> = agent.tools.iter().map(|tool| tool.name()).collect();
assert_eq!(names, vec!["shell"]);
}
#[test]
fn from_config_empty_allowlist_preserves_default_toolset() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let config = base_from_config_for_tool_filter_tests();
let agent = Agent::from_config(&config).expect("agent should build");
let names: Vec<&str> = agent.tools.iter().map(|tool| tool.name()).collect();
assert!(names.contains(&"shell"));
assert!(names.contains(&"file_read"));
}
#[test]
fn from_config_primary_denylist_removes_tools() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let mut config = base_from_config_for_tool_filter_tests();
config.agent.denied_tools = vec!["shell".to_string()];
let agent = Agent::from_config(&config).expect("agent should build");
let names: Vec<&str> = agent.tools.iter().map(|tool| tool.name()).collect();
assert!(!names.contains(&"shell"));
}
#[test]
fn from_config_unmatched_allowlist_entry_is_graceful() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let mut config = base_from_config_for_tool_filter_tests();
config.agent.allowed_tools = vec!["missing_tool".to_string()];
let agent = Agent::from_config(&config).expect("agent should build with empty toolset");
assert!(agent.tools.is_empty());
}
#[test]
fn from_config_conflicting_allow_and_deny_fails_fast() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let mut config = base_from_config_for_tool_filter_tests();
config.agent.allowed_tools = vec!["shell".to_string()];
config.agent.denied_tools = vec!["shell".to_string()];
let err = Agent::from_config(&config)
.err()
.expect("expected filter conflict");
assert!(err
.to_string()
.contains("agent.allowed_tools and agent.denied_tools removed all executable tools"));
}
}

View File

@ -80,6 +80,54 @@ const CANARY_EXFILTRATION_BLOCK_MESSAGE: &str =
/// Matches the channel-side constant in `channels/mod.rs`.
const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
fn filter_primary_agent_tools_or_fail(
config: &Config,
tools_registry: Vec<Box<dyn Tool>>,
) -> Result<Vec<Box<dyn Tool>>> {
let (filtered_tools, report) = tools::filter_primary_agent_tools(
tools_registry,
&config.agent.allowed_tools,
&config.agent.denied_tools,
);
for unmatched in report.unmatched_allowed_tools {
tracing::debug!(
tool = %unmatched,
"agent.allowed_tools entry did not match any registered tool"
);
}
let has_agent_allowlist = config
.agent
.allowed_tools
.iter()
.any(|entry| !entry.trim().is_empty());
let has_agent_denylist = config
.agent
.denied_tools
.iter()
.any(|entry| !entry.trim().is_empty());
if has_agent_allowlist
&& has_agent_denylist
&& report.allowlist_match_count > 0
&& filtered_tools.is_empty()
{
anyhow::bail!(
"agent.allowed_tools and agent.denied_tools removed all executable tools; update [agent] tool filters"
);
}
Ok(filtered_tools)
}
fn retain_visible_tool_descriptions<'a>(
tool_descs: &mut Vec<(&'a str, &'a str)>,
tools_registry: &[Box<dyn Tool>],
) {
let visible_tools: HashSet<&str> = tools_registry.iter().map(|tool| tool.name()).collect();
tool_descs.retain(|(name, _)| visible_tools.contains(*name));
}
fn should_treat_provider_as_vision_capable(provider_name: &str, provider: &dyn Provider) -> bool {
if provider.supports_vision() {
return true;
@ -2641,6 +2689,7 @@ pub async fn run(
tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
tools_registry.extend(peripheral_tools);
}
let tools_registry = filter_primary_agent_tools_or_fail(&config, tools_registry)?;
// ── Resolve provider ─────────────────────────────────────────
let provider_name = provider_override
@ -2829,6 +2878,7 @@ pub async fn run(
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
));
}
retain_visible_tool_descriptions(&mut tool_descs, &tools_registry);
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@ -3271,6 +3321,7 @@ pub async fn process_message_with_session(
let peripheral_tools: Vec<Box<dyn Tool>> =
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
tools_registry.extend(peripheral_tools);
let tools_registry = filter_primary_agent_tools_or_fail(&config, tools_registry)?;
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
let model_name = crate::config::resolve_default_model_id(
@ -3372,6 +3423,7 @@ pub async fn process_message_with_session(
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
));
}
retain_visible_tool_descriptions(&mut tool_descs, &tools_registry);
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {

View File

@ -1044,6 +1044,14 @@ pub struct AgentConfig {
/// Tool dispatch strategy (e.g. `"auto"`). Default: `"auto"`.
#[serde(default = "default_agent_tool_dispatcher")]
pub tool_dispatcher: String,
/// Optional allowlist for primary-agent tool visibility.
/// When non-empty, only listed tools are exposed to the primary agent.
#[serde(default)]
pub allowed_tools: Vec<String>,
/// Optional denylist for primary-agent tool visibility.
/// Applied after `allowed_tools`.
#[serde(default)]
pub denied_tools: Vec<String>,
/// Agent-team runtime controls for synchronous delegation.
#[serde(default)]
pub teams: AgentTeamsConfig,
@ -1179,6 +1187,8 @@ impl Default for AgentConfig {
max_history_messages: default_agent_max_history_messages(),
parallel_tools: false,
tool_dispatcher: default_agent_tool_dispatcher(),
allowed_tools: Vec::new(),
denied_tools: Vec::new(),
teams: AgentTeamsConfig::default(),
subagents: SubAgentsConfig::default(),
loop_detection_no_progress_threshold: default_loop_detection_no_progress_threshold(),
@ -8130,6 +8140,30 @@ impl Config {
);
}
}
for (i, tool_name) in self.agent.allowed_tools.iter().enumerate() {
let normalized = tool_name.trim();
if normalized.is_empty() {
anyhow::bail!("agent.allowed_tools[{i}] must not be empty");
}
if !normalized
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '*')
{
anyhow::bail!("agent.allowed_tools[{i}] contains invalid characters: {normalized}");
}
}
for (i, tool_name) in self.agent.denied_tools.iter().enumerate() {
let normalized = tool_name.trim();
if normalized.is_empty() {
anyhow::bail!("agent.denied_tools[{i}] must not be empty");
}
if !normalized
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '*')
{
anyhow::bail!("agent.denied_tools[{i}] contains invalid characters: {normalized}");
}
}
let built_in_roles = ["owner", "admin", "operator", "viewer", "guest"];
let mut custom_role_names = std::collections::HashSet::new();
for (i, role) in self.security.roles.iter().enumerate() {
@ -10467,6 +10501,8 @@ reasoning_level = "high"
assert_eq!(cfg.max_history_messages, 50);
assert!(!cfg.parallel_tools);
assert_eq!(cfg.tool_dispatcher, "auto");
assert!(cfg.allowed_tools.is_empty());
assert!(cfg.denied_tools.is_empty());
}
#[test]
@ -10479,6 +10515,8 @@ max_tool_iterations = 20
max_history_messages = 80
parallel_tools = true
tool_dispatcher = "xml"
allowed_tools = ["delegate", "task_plan"]
denied_tools = ["shell"]
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert!(parsed.agent.compact_context);
@ -10486,6 +10524,11 @@ tool_dispatcher = "xml"
assert_eq!(parsed.agent.max_history_messages, 80);
assert!(parsed.agent.parallel_tools);
assert_eq!(parsed.agent.tool_dispatcher, "xml");
assert_eq!(
parsed.agent.allowed_tools,
vec!["delegate".to_string(), "task_plan".to_string()]
);
assert_eq!(parsed.agent.denied_tools, vec!["shell".to_string()]);
}
#[tokio::test]
@ -14330,6 +14373,50 @@ sensitivity = 0.9
assert!(err.to_string().contains("gated_domains"));
}
#[test]
async fn agent_validation_rejects_empty_allowed_tool_entry() {
let mut config = Config::default();
config.agent.allowed_tools = vec![" ".to_string()];
let err = config
.validate()
.expect_err("expected invalid agent allowed_tools entry");
assert!(err.to_string().contains("agent.allowed_tools"));
}
#[test]
async fn agent_validation_rejects_invalid_allowed_tool_chars() {
let mut config = Config::default();
config.agent.allowed_tools = vec!["bad tool".to_string()];
let err = config
.validate()
.expect_err("expected invalid agent allowed_tools chars");
assert!(err.to_string().contains("agent.allowed_tools"));
}
#[test]
async fn agent_validation_rejects_empty_denied_tool_entry() {
let mut config = Config::default();
config.agent.denied_tools = vec![" ".to_string()];
let err = config
.validate()
.expect_err("expected invalid agent denied_tools entry");
assert!(err.to_string().contains("agent.denied_tools"));
}
#[test]
async fn agent_validation_rejects_invalid_denied_tool_chars() {
let mut config = Config::default();
config.agent.denied_tools = vec!["bad/tool".to_string()];
let err = config
.validate()
.expect_err("expected invalid agent denied_tools chars");
assert!(err.to_string().contains("agent.denied_tools"));
}
#[test]
async fn security_validation_rejects_invalid_url_access_cidr() {
let mut config = Config::default();

View File

@ -74,6 +74,8 @@ pub mod runtime;
pub(crate) mod security;
pub(crate) mod service;
pub(crate) mod skills;
#[cfg(test)]
pub(crate) mod test_locks;
pub mod tools;
pub(crate) mod tunnel;
pub mod update;

View File

@ -91,6 +91,8 @@ mod security;
mod service;
mod skillforge;
mod skills;
#[cfg(test)]
mod test_locks;
mod tools;
mod tunnel;
mod update;

View File

@ -530,6 +530,7 @@ description = "{tool} description"
#[test]
fn initialize_from_config_applies_updated_plugin_dirs() {
let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
let dir_a = TempDir::new().expect("temp dir a");
let dir_b = TempDir::new().expect("temp dir b");
write_manifest(

4
src/test_locks.rs Normal file
View File

@ -0,0 +1,4 @@
use parking_lot::{const_mutex, Mutex};
// Serialize tests that mutate process-global plugin runtime state.
pub(crate) static PLUGIN_RUNTIME_LOCK: Mutex<()> = const_mutex(());

View File

@ -201,6 +201,90 @@ fn boxed_registry_from_arcs(tools: Vec<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
tools.into_iter().map(ArcDelegatingTool::boxed).collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PrimaryAgentToolFilterReport {
/// `agent.allowed_tools` entries that did not match any registered tool name.
pub unmatched_allowed_tools: Vec<String>,
/// Number of tools kept after applying `agent.allowed_tools` and before denylist removal.
pub allowlist_match_count: usize,
}
fn matches_tool_rule(rule: &str, tool_name: &str) -> bool {
rule == "*" || rule.eq_ignore_ascii_case(tool_name)
}
/// Filter the primary-agent tool registry based on `[agent]` allow/deny settings.
///
/// Filtering is done at startup so excluded tools never enter model context.
pub fn filter_primary_agent_tools(
tools: Vec<Box<dyn Tool>>,
allowed_tools: &[String],
denied_tools: &[String],
) -> (Vec<Box<dyn Tool>>, PrimaryAgentToolFilterReport) {
let normalized_allowed: Vec<String> = allowed_tools
.iter()
.map(|entry| entry.trim())
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect();
let normalized_denied: Vec<String> = denied_tools
.iter()
.map(|entry| entry.trim())
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect();
let use_allowlist = !normalized_allowed.is_empty();
let tool_names: Vec<String> = tools.iter().map(|tool| tool.name().to_string()).collect();
let unmatched_allowed_tools = if use_allowlist {
normalized_allowed
.iter()
.filter(|allowed| {
!tool_names
.iter()
.any(|tool_name| matches_tool_rule(allowed.as_str(), tool_name))
})
.cloned()
.collect()
} else {
Vec::new()
};
let mut allowlist_match_count = 0usize;
let mut filtered = Vec::with_capacity(tools.len());
for tool in tools {
let tool_name = tool.name();
if use_allowlist
&& !normalized_allowed
.iter()
.any(|rule| matches_tool_rule(rule.as_str(), tool_name))
{
continue;
}
if use_allowlist {
allowlist_match_count += 1;
}
if normalized_denied
.iter()
.any(|rule| matches_tool_rule(rule.as_str(), tool_name))
{
continue;
}
filtered.push(tool);
}
(
filtered,
PrimaryAgentToolFilterReport {
unmatched_allowed_tools,
allowlist_match_count,
},
)
}
/// Add background tool execution capabilities to a tool registry
pub fn add_bg_tools(tools: Vec<Box<dyn Tool>>) -> (Vec<Box<dyn Tool>>, BgJobStore) {
let bg_job_store = BgJobStore::new();
@ -709,6 +793,7 @@ mod tests {
use super::*;
use crate::config::{BrowserConfig, Config, MemoryConfig, WasmRuntimeConfig};
use crate::runtime::WasmRuntime;
use serde_json::json;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
@ -719,6 +804,96 @@ mod tests {
}
}
struct DummyTool {
name: &'static str,
}
#[async_trait::async_trait]
impl Tool for DummyTool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"dummy"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {}
})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: "ok".to_string(),
error: None,
})
}
}
fn sample_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(DummyTool { name: "shell" }),
Box::new(DummyTool { name: "file_read" }),
Box::new(DummyTool {
name: "browser_open",
}),
]
}
fn names(tools: &[Box<dyn Tool>]) -> Vec<String> {
tools.iter().map(|tool| tool.name().to_string()).collect()
}
#[test]
fn filter_primary_agent_tools_keeps_full_registry_when_allowlist_empty() {
let (filtered, report) = filter_primary_agent_tools(sample_tools(), &[], &[]);
assert_eq!(names(&filtered), vec!["shell", "file_read", "browser_open"]);
assert_eq!(report.allowlist_match_count, 0);
assert!(report.unmatched_allowed_tools.is_empty());
}
#[test]
fn filter_primary_agent_tools_applies_allowlist() {
let allow = vec!["file_read".to_string()];
let (filtered, report) = filter_primary_agent_tools(sample_tools(), &allow, &[]);
assert_eq!(names(&filtered), vec!["file_read"]);
assert_eq!(report.allowlist_match_count, 1);
assert!(report.unmatched_allowed_tools.is_empty());
}
#[test]
fn filter_primary_agent_tools_reports_unmatched_allow_entries() {
let allow = vec!["missing_tool".to_string()];
let (filtered, report) = filter_primary_agent_tools(sample_tools(), &allow, &[]);
assert!(filtered.is_empty());
assert_eq!(report.allowlist_match_count, 0);
assert_eq!(report.unmatched_allowed_tools, vec!["missing_tool"]);
}
#[test]
fn filter_primary_agent_tools_applies_denylist_after_allowlist() {
let allow = vec!["shell".to_string(), "file_read".to_string()];
let deny = vec!["shell".to_string()];
let (filtered, report) = filter_primary_agent_tools(sample_tools(), &allow, &deny);
assert_eq!(names(&filtered), vec!["file_read"]);
assert_eq!(report.allowlist_match_count, 2);
assert!(report.unmatched_allowed_tools.is_empty());
}
#[test]
fn filter_primary_agent_tools_supports_star_rule() {
let allow = vec!["*".to_string()];
let deny = vec!["browser_open".to_string()];
let (filtered, report) = filter_primary_agent_tools(sample_tools(), &allow, &deny);
assert_eq!(names(&filtered), vec!["shell", "file_read"]);
assert_eq!(report.allowlist_match_count, 3);
assert!(report.unmatched_allowed_tools.is_empty());
}
#[test]
fn default_tools_has_expected_count() {
let security = Arc::new(SecurityPolicy::default());