fix(policy): standardize side-effect tool autonomy gates
This commit is contained in:
parent
89d0fb9a1e
commit
4f9c87ff74
6 changed files with 369 additions and 38 deletions
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue