use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RiskLevel {
Safe,
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for RiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Safe => write!(f, "safe"),
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SafetyAction {
Allow,
Warn,
Block,
}
#[derive(Debug, Clone)]
pub struct SafetyDecision {
pub action: SafetyAction,
pub reason: Option<String>,
pub category: Option<String>,
}
impl SafetyDecision {
#[must_use]
pub fn allow() -> Self {
Self {
action: SafetyAction::Allow,
reason: None,
category: None,
}
}
#[must_use]
pub fn warn(reason: String, category: &str) -> Self {
Self {
action: SafetyAction::Warn,
reason: Some(reason),
category: Some(category.to_string()),
}
}
#[must_use]
pub fn block(reason: String, category: &str) -> Self {
Self {
action: SafetyAction::Block,
reason: Some(reason),
category: Some(category.to_string()),
}
}
#[must_use]
pub fn is_allowed(&self) -> bool {
self.action == SafetyAction::Allow
}
#[must_use]
pub fn is_blocked(&self) -> bool {
self.action == SafetyAction::Block
}
#[must_use]
pub fn is_warn(&self) -> bool {
self.action == SafetyAction::Warn
}
}
#[derive(Debug, Clone)]
pub struct ShieldContext {
pub tool_name: String,
pub input: Value,
pub turn: usize,
pub recent_calls: Vec<(String, usize)>,
}
#[derive(Clone)]
pub struct RiskPattern {
pub name: &'static str,
pub score: f32,
pub pattern: &'static str,
}
#[derive(Clone)]
pub struct CombinationRule {
pub description: &'static str,
pub score: f32,
pub triggers: &'static [(&'static str, Option<&'static str>)],
}
pub trait ToolSafetyShield: Send + Sync {
fn evaluate(&self, ctx: &ShieldContext) -> SafetyDecision;
fn record_invocation(&self, tool_name: &str, input: &Value, success: bool);
fn watched_tools(&self) -> HashSet<String>;
}
pub struct UnixShield {
warn_threshold: f32,
block_threshold: f32,
turn_history: Mutex<Vec<(String, String)>>,
patterns: HashMap<&'static str, Vec<RiskPattern>>,
combination_rules: Vec<CombinationRule>,
}
impl UnixShield {
#[must_use]
pub fn new() -> Self {
Self {
warn_threshold: 0.4,
block_threshold: 0.7,
turn_history: Mutex::new(Vec::new()),
patterns: Self::unix_patterns(),
combination_rules: Self::unix_combination_rules(),
}
}
#[must_use]
pub fn with_thresholds(mut self, warn: f32, block: f32) -> Self {
self.warn_threshold = warn.clamp(0.0, 1.0);
self.block_threshold = block.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn builder() -> UnixShieldBuilder {
UnixShieldBuilder::new()
}
pub fn assess_single_tool_risk(&self, tool_name: &str, input: &Value) -> f32 {
let Some(patterns) = self.patterns.get(tool_name) else {
return 0.0;
};
let input_str = input.to_string();
let mut max_score = 0.0_f32;
for p in patterns {
if input_str.contains(p.pattern) {
max_score = max_score.max(p.score);
}
}
max_score
}
pub fn assess_multi_turn(&self, ctx: &ShieldContext) -> f32 {
let history = self
.turn_history
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let same_tool_count = history
.iter()
.filter(|(name, _)| name == &ctx.tool_name)
.count();
match same_tool_count {
0 => 0.0,
1 => 0.1,
2 => 0.3,
_ => 0.6,
}
}
pub fn assess_combination(&self, ctx: &ShieldContext) -> f32 {
let history = self
.turn_history
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut max_risk = 0.0_f32;
let current_event = (ctx.tool_name.as_str(), ctx.input.to_string());
let candidates: Vec<(&str, String)> = history
.iter()
.map(|(name, input)| (name.as_str(), input.clone()))
.chain(std::iter::once((current_event.0, current_event.1.clone())))
.collect();
for rule in &self.combination_rules {
let mut trigger_idx: usize = 0;
for (name, input) in &candidates {
let Some(&(tool, pattern)) = rule.triggers.get(trigger_idx) else {
break;
};
if *name == tool && pattern.is_none_or(|p| input.contains(p)) {
trigger_idx = trigger_idx.saturating_add(1);
if trigger_idx == rule.triggers.len() {
break;
}
}
}
if trigger_idx == rule.triggers.len() {
max_risk = max_risk.max(rule.score);
}
}
max_risk
}
fn score_to_level(&self, score: f32) -> RiskLevel {
if score >= 0.9 {
RiskLevel::Critical
} else if score >= self.block_threshold {
RiskLevel::High
} else if score >= self.warn_threshold {
RiskLevel::Medium
} else if score >= 0.2 {
RiskLevel::Low
} else {
RiskLevel::Safe
}
}
fn unix_patterns() -> HashMap<&'static str, Vec<RiskPattern>> {
let mut m = HashMap::new();
m.insert(
"Bash",
vec![
RiskPattern {
name: "recursive_delete",
score: 0.9,
pattern: "rm -rf",
},
RiskPattern {
name: "force_delete",
score: 0.7,
pattern: "rm -f",
},
RiskPattern {
name: "sudo",
score: 0.6,
pattern: "sudo",
},
RiskPattern {
name: "chmod_777",
score: 0.5,
pattern: "chmod 777",
},
RiskPattern {
name: "shell_pipe_to_sh",
score: 0.8,
pattern: "| sh",
},
RiskPattern {
name: "curl_pipe_sh",
score: 0.9,
pattern: "curl",
},
RiskPattern {
name: "wget_pipe",
score: 0.7,
pattern: "wget",
},
],
);
m.insert(
"Write",
vec![
RiskPattern {
name: "write_bin",
score: 0.6,
pattern: "/usr/bin",
},
RiskPattern {
name: "write_bin_sbin",
score: 0.6,
pattern: "/usr/sbin",
},
RiskPattern {
name: "write_etc",
score: 0.7,
pattern: "/etc/",
},
RiskPattern {
name: "write_ssh",
score: 0.8,
pattern: ".ssh/",
},
],
);
m.insert(
"Edit",
vec![
RiskPattern {
name: "edit_etc",
score: 0.5,
pattern: "/etc/",
},
RiskPattern {
name: "edit_ssh",
score: 0.7,
pattern: ".ssh/",
},
],
);
m
}
fn unix_combination_rules() -> Vec<CombinationRule> {
vec![
CombinationRule {
description: "write then execute",
score: 0.75,
triggers: &[("Write", None), ("Bash", Some("chmod +x"))],
},
CombinationRule {
description: "download then execute",
score: 0.85,
triggers: &[("Bash", Some("curl")), ("Bash", Some("| sh"))],
},
CombinationRule {
description: "modify permissions then write",
score: 0.65,
triggers: &[("Bash", Some("chmod")), ("Write", None)],
},
]
}
}
impl Default for UnixShield {
fn default() -> Self {
Self::new()
}
}
impl ToolSafetyShield for UnixShield {
fn evaluate(&self, ctx: &ShieldContext) -> SafetyDecision {
let single = self.assess_single_tool_risk(&ctx.tool_name, &ctx.input);
let multi = self.assess_multi_turn(ctx);
let combo = self.assess_combination(ctx);
let total = (single + multi * 0.5 + combo * 0.3).min(1.0);
let level = self.score_to_level(total);
match level {
RiskLevel::Safe | RiskLevel::Low => SafetyDecision::allow(),
RiskLevel::Medium => SafetyDecision::warn(
format!("Moderate risk detected (score={total:.2})"),
"safety_evaluation",
),
RiskLevel::High | RiskLevel::Critical => SafetyDecision::block(
format!("High risk blocked (score={total:.2}, category={level})"),
"safety_evaluation",
),
}
}
fn record_invocation(&self, tool_name: &str, input: &Value, _success: bool) {
let mut history = self
.turn_history
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
history.push((tool_name.to_string(), input.to_string()));
if history.len() > 20 {
let drain_until = history.len().saturating_sub(20);
history.drain(..drain_until);
}
}
fn watched_tools(&self) -> HashSet<String> {
self.patterns.keys().map(|k| (*k).to_string()).collect()
}
}
pub struct UnixShieldBuilder {
warn_threshold: f32,
block_threshold: f32,
patterns: HashMap<&'static str, Vec<RiskPattern>>,
combination_rules: Vec<CombinationRule>,
}
impl UnixShieldBuilder {
#[must_use]
pub fn new() -> Self {
Self {
warn_threshold: 0.4,
block_threshold: 0.7,
patterns: UnixShield::unix_patterns(),
combination_rules: UnixShield::unix_combination_rules(),
}
}
#[must_use]
pub fn blank() -> Self {
Self {
warn_threshold: 0.4,
block_threshold: 0.7,
patterns: HashMap::new(),
combination_rules: Vec::new(),
}
}
#[must_use]
pub fn warn_threshold(mut self, threshold: f32) -> Self {
self.warn_threshold = threshold.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn block_threshold(mut self, threshold: f32) -> Self {
self.block_threshold = threshold.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn pattern(mut self, tool_name: &'static str, patterns: Vec<RiskPattern>) -> Self {
self.patterns.entry(tool_name).or_default().extend(patterns);
self
}
#[must_use]
pub fn combination_rule(mut self, rule: CombinationRule) -> Self {
self.combination_rules.push(rule);
self
}
#[must_use]
pub fn build(self) -> UnixShield {
UnixShield {
warn_threshold: self.warn_threshold,
block_threshold: self.block_threshold,
turn_history: Mutex::new(Vec::new()),
patterns: self.patterns,
combination_rules: self.combination_rules,
}
}
}
impl Default for UnixShieldBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct NullShield;
impl ToolSafetyShield for NullShield {
fn evaluate(&self, _ctx: &ShieldContext) -> SafetyDecision {
SafetyDecision::allow()
}
fn record_invocation(&self, _tool_name: &str, _input: &Value, _success: bool) {
}
fn watched_tools(&self) -> HashSet<String> {
HashSet::new()
}
}
impl Default for NullShield {
fn default() -> Self {
Self
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn ctx(name: &str, input: Value, turn: usize) -> ShieldContext {
ShieldContext {
tool_name: name.to_string(),
input,
turn,
recent_calls: vec![],
}
}
#[test]
fn null_shield_allows_everything() {
let shield = NullShield;
let decision = shield.evaluate(&ctx("Bash", json!("rm -rf /"), 0));
assert!(decision.is_allowed());
assert!(shield.watched_tools().is_empty());
}
#[test]
fn null_shield_default_trait() {
let shield = NullShield;
let _ = &shield;
}
#[test]
fn unix_shield_allows_safe_command() {
let shield = UnixShield::new();
let decision = shield.evaluate(&ctx("Bash", json!({ "command": "ls -la" }), 0));
assert!(decision.is_allowed());
}
#[test]
fn unix_shield_blocks_recursive_delete() {
let shield = UnixShield::new();
let decision = shield.evaluate(&ctx("Bash", json!({ "command": "rm -rf /" }), 0));
assert!(decision.is_blocked());
}
#[test]
fn unix_shield_warns_on_sudo() {
let shield = UnixShield::new();
let decision = shield.evaluate(&ctx("Bash", json!({ "command": "sudo apt update" }), 0));
assert!(decision.is_warn() || decision.is_blocked());
}
#[test]
fn unix_shield_allows_read() {
let shield = UnixShield::new();
let decision = shield.evaluate(&ctx("Read", json!({ "path": "/tmp/hello.txt" }), 0));
assert!(decision.is_allowed());
}
#[test]
fn unix_shield_records_and_trims_history() {
let shield = UnixShield::new();
for i in 0..25 {
shield.record_invocation("Bash", &json!({ "command": "ls" }), true);
let history = shield.turn_history.lock().unwrap();
assert!(history.len() <= 20, "iteration {i}: history too long");
}
}
#[test]
fn unix_shield_watched_tools_derived_from_patterns() {
let shield = UnixShield::new();
let watched = shield.watched_tools();
assert!(watched.contains("Bash"));
assert!(watched.contains("Write"));
assert!(watched.contains("Edit"));
}
#[test]
fn unix_shield_default_trait() {
let _shield = UnixShield::default();
}
#[test]
fn combination_rule_requires_chronological_order() {
let shield = UnixShieldBuilder::blank()
.combination_rule(CombinationRule {
description: "write then execute",
score: 0.75,
triggers: &[("Write", None), ("Bash", Some("chmod +x"))],
})
.build();
shield.record_invocation("Bash", &json!({ "command": "chmod +x /tmp/payload" }), true);
shield.record_invocation("Write", &json!({ "path": "/tmp/payload" }), true);
let eval_ctx = ctx("Read", json!({ "path": "/tmp/data" }), 2);
let combo = shield.assess_combination(&eval_ctx);
assert!(
(combo - 0.0).abs() < f32::EPSILON,
"reversed order should not match"
);
let shield2 = UnixShieldBuilder::blank()
.combination_rule(CombinationRule {
description: "write then execute",
score: 0.75,
triggers: &[("Write", None), ("Bash", Some("chmod +x"))],
})
.build();
shield2.record_invocation("Write", &json!({ "path": "/tmp/payload" }), true);
let eval_ctx2 = ctx("Bash", json!({ "command": "chmod +x /tmp/payload" }), 1);
let combo2 = shield2.assess_combination(&eval_ctx2);
assert!(combo2 > 0.0, "correct order should match");
}
#[test]
fn builder_blank_has_no_patterns() {
let shield = UnixShieldBuilder::blank().build();
let watched = shield.watched_tools();
assert!(watched.is_empty());
}
#[test]
fn builder_custom_pattern() {
let shield = UnixShieldBuilder::blank()
.pattern(
"PowerShell",
vec![RiskPattern {
name: "remove_item",
score: 0.9,
pattern: "Remove-Item",
}],
)
.build();
let watched = shield.watched_tools();
assert!(watched.contains("PowerShell"));
assert!(!watched.contains("Bash"));
let decision = shield.evaluate(&ctx(
"PowerShell",
json!({ "command": "Remove-Item -Recurse -Force C:\\" }),
0,
));
assert!(decision.is_blocked());
}
#[test]
fn builder_custom_thresholds() {
let shield = UnixShield::builder()
.warn_threshold(0.1)
.block_threshold(0.2)
.build();
let decision = shield.evaluate(&ctx("Bash", json!({ "command": "sudo ls" }), 0));
assert!(decision.is_blocked());
}
#[test]
fn builder_default_trait() {
let _builder = UnixShieldBuilder::default();
}
#[test]
fn risk_level_display() {
assert_eq!(RiskLevel::Safe.to_string(), "safe");
assert_eq!(RiskLevel::Critical.to_string(), "critical");
}
#[test]
fn safety_decision_accessors() {
let allow = SafetyDecision::allow();
assert!(allow.is_allowed());
assert!(!allow.is_blocked());
assert!(!allow.is_warn());
let warn = SafetyDecision::warn("test".into(), "cat");
assert!(warn.is_warn());
let block = SafetyDecision::block("test".into(), "cat");
assert!(block.is_blocked());
}
}