use std::collections::{HashMap, HashSet};
use crate::base::AgentError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ToolRisk {
Safe,
Standard,
Dangerous,
}
impl ToolRisk {
pub fn name(&self) -> &'static str {
match self {
ToolRisk::Safe => "safe",
ToolRisk::Standard => "standard",
ToolRisk::Dangerous => "dangerous",
}
}
}
impl std::fmt::Display for ToolRisk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[derive(Debug, Clone)]
pub struct ToolPolicy {
risks: HashMap<String, ToolRisk>,
default_risk: ToolRisk,
max_permitted: ToolRisk,
sandboxed: HashSet<String>,
allow_unrestricted_dangerous: bool,
}
impl Default for ToolPolicy {
fn default() -> Self {
Self::new()
}
}
impl ToolPolicy {
pub fn new() -> Self {
Self {
risks: HashMap::new(),
default_risk: ToolRisk::Safe,
max_permitted: ToolRisk::Dangerous,
sandboxed: HashSet::new(),
allow_unrestricted_dangerous: false,
}
}
pub fn risk(mut self, name: impl Into<String>, risk: ToolRisk) -> Self {
self.risks.insert(name.into(), risk);
self
}
pub fn sandboxed(mut self, name: impl Into<String>) -> Self {
self.sandboxed.insert(name.into());
self
}
pub fn with_default_risk(mut self, risk: ToolRisk) -> Self {
self.default_risk = risk;
self
}
pub fn with_max_permitted(mut self, risk: ToolRisk) -> Self {
self.max_permitted = risk;
self
}
pub fn allow_unrestricted_dangerous(mut self, allow: bool) -> Self {
self.allow_unrestricted_dangerous = allow;
self
}
pub fn risk_of(&self, name: &str) -> ToolRisk {
self.risks.get(name).copied().unwrap_or(self.default_risk)
}
pub fn check(&self, name: &str) -> Result<(), AgentError> {
let risk = self.risk_of(name);
if risk > self.max_permitted {
return Err(AgentError::Other(format!(
"tool '{name}' requires permission tier '{risk}', max permitted is '{}'",
self.max_permitted
)));
}
if risk == ToolRisk::Dangerous
&& !self.sandboxed.contains(name)
&& !self.allow_unrestricted_dangerous
{
return Err(AgentError::Other(format!(
"dangerous tool '{name}' must run in a sandboxed environment \
(declare via ToolPolicy::sandboxed(\"{name}\"))"
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_risk_display_and_order() {
assert_eq!(ToolRisk::Safe.to_string(), "safe");
assert_eq!(ToolRisk::Standard.to_string(), "standard");
assert_eq!(ToolRisk::Dangerous.to_string(), "dangerous");
assert!(ToolRisk::Safe < ToolRisk::Standard);
assert!(ToolRisk::Standard < ToolRisk::Dangerous);
}
#[test]
fn test_policy_allows_default_safe_tools() {
let policy = ToolPolicy::new();
assert!(policy.check("any_tool").is_ok());
assert_eq!(policy.risk_of("any_tool"), ToolRisk::Safe);
}
#[test]
fn test_policy_permission_tier_gate() {
let policy = ToolPolicy::new()
.risk("calculator", ToolRisk::Dangerous)
.with_max_permitted(ToolRisk::Standard);
let err = policy.check("calculator").unwrap_err();
assert!(err.to_string().contains("permission tier"), "{}", err);
assert!(policy.check("other").is_ok());
}
#[test]
fn test_policy_dangerous_requires_sandbox() {
let policy = ToolPolicy::new().risk("code_interpreter", ToolRisk::Dangerous);
let err = policy.check("code_interpreter").unwrap_err();
assert!(err.to_string().contains("sandboxed"), "{}", err);
let policy = policy.sandboxed("code_interpreter");
assert!(policy.check("code_interpreter").is_ok());
}
#[test]
fn test_policy_allow_unrestricted_dangerous() {
let policy = ToolPolicy::new()
.risk("http", ToolRisk::Dangerous)
.allow_unrestricted_dangerous(true);
assert!(policy.check("http").is_ok());
}
#[test]
fn test_policy_sandboxed_but_not_dangerous_still_gated_by_tier() {
let policy = ToolPolicy::new()
.risk("calculator", ToolRisk::Dangerous)
.sandboxed("calculator")
.with_max_permitted(ToolRisk::Standard);
let err = policy.check("calculator").unwrap_err();
assert!(err.to_string().contains("permission tier"), "{}", err);
}
}