#![forbid(unsafe_code)]
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::RwLock;
use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxFirewallPolicy {
pub enabled: bool,
pub allowed_tool_prefixes: Vec<String>,
pub deny_unknown_tools: bool,
pub max_ops_per_transaction: u32,
pub require_rollback_confirmation: bool,
}
impl Default for TxFirewallPolicy {
fn default() -> Self {
Self {
enabled: false,
allowed_tool_prefixes: vec!["memory.".into(), "galaxy.".into()],
deny_unknown_tools: true,
max_ops_per_transaction: 50,
require_rollback_confirmation: true,
}
}
}
impl TxFirewallPolicy {
#[must_use]
pub fn strict() -> Self {
Self {
enabled: true,
allowed_tool_prefixes: vec!["memory.".into()],
deny_unknown_tools: true,
max_ops_per_transaction: 20,
require_rollback_confirmation: true,
}
}
}
#[derive(Debug, Default)]
pub struct TxFirewall {
policy: RwLock<TxFirewallPolicy>,
}
impl TxFirewall {
#[must_use]
pub fn new() -> Self {
Self {
policy: RwLock::new(TxFirewallPolicy::default()),
}
}
pub fn set_policy(&self, policy: TxFirewallPolicy) {
if let Ok(mut p) = self.policy.write() {
*p = policy;
}
}
#[must_use]
pub fn policy(&self) -> TxFirewallPolicy {
self.policy.read().map(|p| p.clone()).unwrap_or_default()
}
pub fn check(&self, tool: &str) -> Result<(), String> {
let policy = self.policy();
if !policy.enabled {
return Ok(());
}
let allowed = policy
.allowed_tool_prefixes
.iter()
.any(|prefix| tool.starts_with(prefix.as_str()));
if allowed {
Ok(())
} else if policy.deny_unknown_tools {
Err(format!(
"tx firewall: tool '{tool}' is not allowed in transactions"
))
} else {
Ok(())
}
}
#[must_use]
pub fn to_json(&self) -> Value {
serde_json::to_value(self.policy()).unwrap_or_else(|_| json!({}))
}
pub fn from_json(&self, value: &Value) -> Result<(), String> {
let policy: TxFirewallPolicy =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
self.set_policy(policy);
Ok(())
}
}
pub struct TxFirewallSetPolicyTool {
firewall: std::sync::Arc<TxFirewall>,
stats: ToolStats,
effects: EffectRow,
}
impl TxFirewallSetPolicyTool {
#[must_use]
pub fn new(firewall: std::sync::Arc<TxFirewall>) -> Self {
Self {
firewall,
stats: ToolStats::default(),
effects: EffectRow {
writes: vec![Resource::DharmaRules],
..Default::default()
},
}
}
}
#[async_trait]
impl Tool for TxFirewallSetPolicyTool {
fn name(&self) -> &str {
"tx_firewall.set_policy"
}
fn gana(&self) -> Gana {
Gana::Room
}
fn effects(&self) -> &EffectRow {
&self.effects
}
fn description(&self) -> &str {
"Set the transaction firewall policy. Args: enabled (bool), allowed_tool_prefixes (list), deny_unknown_tools (bool), max_ops_per_transaction (int), require_rollback_confirmation (bool), or profile: \"default\" | \"strict\"."
}
async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
let profile = args.get("profile").and_then(Value::as_str);
let mut policy = self.firewall.policy();
match profile {
Some("strict") => policy = TxFirewallPolicy::strict(),
Some("default") => policy = TxFirewallPolicy::default(),
Some(other) => {
return Err(wm_core::CoreError::InvalidArgs(format!(
"unknown profile '{other}' (expected 'default' or 'strict')"
)));
}
None => {}
}
if let Some(v) = args.get("enabled").and_then(Value::as_bool) {
policy.enabled = v;
}
if let Some(v) = args.get("allowed_tool_prefixes").and_then(Value::as_array) {
policy.allowed_tool_prefixes = v
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
}
if let Some(v) = args.get("deny_unknown_tools").and_then(Value::as_bool) {
policy.deny_unknown_tools = v;
}
if let Some(v) = args.get("max_ops_per_transaction").and_then(Value::as_u64) {
policy.max_ops_per_transaction = v as u32;
}
if let Some(v) = args
.get("require_rollback_confirmation")
.and_then(Value::as_bool)
{
policy.require_rollback_confirmation = v;
}
self.firewall.set_policy(policy.clone());
Ok(json!({
"status": "success",
"policy": policy,
}))
}
fn stats(&self) -> &ToolStats {
&self.stats
}
}
pub struct TxFirewallStatusTool {
firewall: std::sync::Arc<TxFirewall>,
stats: ToolStats,
effects: EffectRow,
}
impl TxFirewallStatusTool {
#[must_use]
pub fn new(firewall: std::sync::Arc<TxFirewall>) -> Self {
Self {
firewall,
stats: ToolStats::default(),
effects: EffectRow::read_only(vec![Resource::DharmaRules]),
}
}
}
#[async_trait]
impl Tool for TxFirewallStatusTool {
fn name(&self) -> &str {
"tx_firewall.status"
}
fn gana(&self) -> Gana {
Gana::Room
}
fn effects(&self) -> &EffectRow {
&self.effects
}
fn description(&self) -> &str {
"Show the transaction firewall policy, optionally checking a tool against it (check_tool arg)."
}
async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
let policy = self.firewall.policy();
let mut result = json!({
"status": "success",
"policy": policy,
});
if let Some(tool) = args.get("check_tool").and_then(Value::as_str) {
match self.firewall.check(tool) {
Ok(()) => {
result["check"] = json!({"tool": tool, "allowed": true});
}
Err(reason) => {
result["check"] = json!({"tool": tool, "allowed": false, "reason": reason});
}
}
}
Ok(result)
}
fn stats(&self) -> &ToolStats {
&self.stats
}
}
#[must_use]
pub fn register_firewall(
registry: &wm_dispatch::ToolRegistry,
firewall: std::sync::Arc<TxFirewall>,
) -> wm_dispatch::ToolRegistry {
registry
.register(std::sync::Arc::new(TxFirewallSetPolicyTool::new(
firewall.clone(),
)))
.register(std::sync::Arc::new(TxFirewallStatusTool::new(firewall)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_allows_memory_prefix() {
let firewall = TxFirewall::new();
assert!(firewall.check("memory.create").is_ok());
assert!(firewall.check("galaxy.stats").is_ok());
assert!(firewall.check("web.fetch").is_ok()); }
#[test]
fn strict_policy_denies_unknown_tools() {
let firewall = TxFirewall::new();
firewall.set_policy(TxFirewallPolicy::strict());
assert!(firewall.check("memory.create").is_ok());
assert!(firewall.check("web.fetch").is_err());
assert!(firewall.check("galaxy.stats").is_err());
}
#[test]
fn disabled_firewall_allows_everything() {
let firewall = TxFirewall::new();
firewall.set_policy(TxFirewallPolicy {
enabled: false,
..TxFirewallPolicy::default()
});
assert!(firewall.check("anything.else").is_ok());
}
#[test]
fn json_roundtrip() {
let firewall = TxFirewall::new();
firewall.set_policy(TxFirewallPolicy::strict());
let json = firewall.to_json();
let restored = TxFirewall::new();
restored.from_json(&json).unwrap();
assert!(restored.check("web.fetch").is_err());
assert!(restored.check("memory.read").is_ok());
assert_eq!(restored.policy().max_ops_per_transaction, 20);
}
}