fix(policy): standardize side-effect tool autonomy gates

This commit is contained in:
fettpl 2026-02-17 22:56:07 +01:00 committed by Chummy
parent 89d0fb9a1e
commit 4f9c87ff74
6 changed files with 369 additions and 38 deletions

View file

@ -1,6 +1,8 @@
use super::traits::{Tool, ToolResult};
use crate::config::DelegateAgentConfig;
use crate::providers::{self, Provider};
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashMap;
@ -16,6 +18,7 @@ const DELEGATE_TIMEOUT_SECS: u64 = 120;
/// summarization) to purpose-built sub-agents.
pub struct DelegateTool {
agents: Arc<HashMap<String, DelegateAgentConfig>>,
security: Arc<SecurityPolicy>,
/// Global credential fallback (from config.api_key)
fallback_credential: Option<String>,
/// Depth at which this tool instance lives in the delegation chain.
@ -26,9 +29,11 @@ impl DelegateTool {
pub fn new(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
) -> Self {
Self {
agents: Arc::new(agents),
security,
fallback_credential,
depth: 0,
}
@ -40,10 +45,12 @@ impl DelegateTool {
pub fn with_depth(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
depth: u32,
) -> Self {
Self {
agents: Arc::new(agents),
security,
fallback_credential,
depth,
}
@ -164,6 +171,17 @@ impl Tool for DelegateTool {
});
}
if let Err(error) = self
.security
.enforce_tool_operation(ToolOperation::Act, "delegate")
{
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
// Create provider for this agent
let provider_credential_owned = agent_config
.api_key
@ -250,6 +268,11 @@ impl Tool for DelegateTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_security() -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy::default())
}
fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
let mut agents = HashMap::new();
@ -280,7 +303,7 @@ mod tests {
#[test]
fn name_and_schema() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
assert_eq!(tool.name(), "delegate");
let schema = tool.parameters_schema();
assert!(schema["properties"]["agent"].is_object());
@ -296,13 +319,13 @@ mod tests {
#[test]
fn description_not_empty() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
assert!(!tool.description().is_empty());
}
#[test]
fn schema_lists_agent_names() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
let schema = tool.parameters_schema();
let desc = schema["properties"]["agent"]["description"]
.as_str()
@ -312,21 +335,21 @@ mod tests {
#[tokio::test]
async fn missing_agent_param() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool.execute(json!({"prompt": "test"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn missing_prompt_param() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool.execute(json!({"agent": "researcher"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn unknown_agent_returns_error() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": "nonexistent", "prompt": "test"}))
.await
@ -337,7 +360,7 @@ mod tests {
#[tokio::test]
async fn depth_limit_enforced() {
let tool = DelegateTool::with_depth(sample_agents(), None, 3);
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
@ -349,7 +372,7 @@ mod tests {
#[tokio::test]
async fn depth_limit_per_agent() {
// coder has max_depth=2, so depth=2 should be blocked
let tool = DelegateTool::with_depth(sample_agents(), None, 2);
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
let result = tool
.execute(json!({"agent": "coder", "prompt": "test"}))
.await
@ -360,7 +383,7 @@ mod tests {
#[test]
fn empty_agents_schema() {
let tool = DelegateTool::new(HashMap::new(), None);
let tool = DelegateTool::new(HashMap::new(), None, test_security());
let schema = tool.parameters_schema();
let desc = schema["properties"]["agent"]["description"]
.as_str()
@ -382,7 +405,7 @@ mod tests {
max_depth: 3,
},
);
let tool = DelegateTool::new(agents, None);
let tool = DelegateTool::new(agents, None, test_security());
let result = tool
.execute(json!({"agent": "broken", "prompt": "test"}))
.await
@ -393,7 +416,7 @@ mod tests {
#[tokio::test]
async fn blank_agent_rejected() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": " ", "prompt": "test"}))
.await
@ -404,7 +427,7 @@ mod tests {
#[tokio::test]
async fn blank_prompt_rejected() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": "researcher", "prompt": " \t "}))
.await
@ -415,7 +438,7 @@ mod tests {
#[tokio::test]
async fn whitespace_agent_name_trimmed_and_found() {
let tool = DelegateTool::new(sample_agents(), None);
let tool = DelegateTool::new(sample_agents(), None, test_security());
// " researcher " with surrounding whitespace — after trim becomes "researcher"
let result = tool
.execute(json!({"agent": " researcher ", "prompt": "test"}))
@ -432,4 +455,42 @@ mod tests {
.contains("Unknown agent")
);
}
#[tokio::test]
async fn delegation_blocked_in_readonly_mode() {
let readonly = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = DelegateTool::new(sample_agents(), None, readonly);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("read-only mode"));
}
#[tokio::test]
async fn delegation_blocked_when_rate_limited() {
let limited = Arc::new(SecurityPolicy {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = DelegateTool::new(sample_agents(), None, limited);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Rate limit exceeded"));
}
}