use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum BehaviorMode {
#[default]
Strict,
Explore,
Shadow,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnabledCapabilities {
pub atoms: Vec<String>,
pub macros: Vec<String>,
pub playbooks: Vec<String>,
}
pub type CapabilityParams = HashMap<String, serde_json::Value>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardConfig {
pub atoms: Option<AtomGuards>,
pub macros: Option<MacroGuards>,
pub playbooks: Option<PlaybookGuards>,
}
impl Default for GuardConfig {
fn default() -> Self {
Self {
atoms: Some(AtomGuards::default()),
macros: Some(MacroGuards::default()),
playbooks: Some(PlaybookGuards::default()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtomGuards {
pub default_max_bytes: u64,
pub require_justification: bool,
}
impl Default for AtomGuards {
fn default() -> Self {
Self {
default_max_bytes: 1048576, require_justification: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MacroGuards {
pub template_validation: ValidationLevel,
}
impl Default for MacroGuards {
fn default() -> Self {
Self {
template_validation: ValidationLevel::Strict,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookGuards {
pub parallel_execution: bool,
pub max_steps: u32,
}
impl Default for PlaybookGuards {
fn default() -> Self {
Self {
parallel_execution: false,
max_steps: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationLevel {
Strict,
Permissive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorPack {
pub name: String,
pub mode: BehaviorMode,
pub enable: EnabledCapabilities,
#[serde(default)]
pub params: CapabilityParams,
#[serde(default)]
pub guards: GuardConfig,
}
#[derive(Debug)]
pub struct BehaviorPackManager {
config_dir: PathBuf,
packs: HashMap<String, BehaviorPack>,
file_times: HashMap<PathBuf, SystemTime>,
poll_interval: Duration,
}
impl BehaviorPackManager {
pub fn new<P: AsRef<Path>>(config_dir: P) -> Self {
Self {
config_dir: config_dir.as_ref().to_path_buf(),
packs: HashMap::new(),
file_times: HashMap::new(),
poll_interval: Duration::from_secs(5), }
}
pub fn load_all(&mut self) -> Result<()> {
let entries = std::fs::read_dir(&self.config_dir).with_context(|| {
format!(
"Failed to read behavior config directory: {}",
self.config_dir.display()
)
})?;
for entry in entries {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml")
|| path.extension().and_then(|s| s.to_str()) == Some("yml")
{
self.load_pack(&path)?;
}
}
Ok(())
}
pub fn load_pack(&mut self, path: &Path) -> Result<()> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read behavior pack file: {}", path.display()))?;
let pack: BehaviorPack = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse behavior pack YAML: {}", path.display()))?;
pack.validate()?;
let metadata = std::fs::metadata(path)
.with_context(|| format!("Failed to get file metadata: {}", path.display()))?;
if let Ok(modified) = metadata.modified() {
self.file_times.insert(path.to_path_buf(), modified);
}
self.packs.insert(pack.name.clone(), pack);
tracing::info!("Loaded behavior pack from {}", path.display());
Ok(())
}
pub fn get_pack(&self, name: &str) -> Option<&BehaviorPack> {
self.packs.get(name)
}
pub fn list_packs(&self) -> Vec<String> {
self.packs.keys().cloned().collect()
}
pub fn check_and_reload(&mut self) -> Result<Vec<String>> {
let mut reloaded = Vec::new();
let entries = match std::fs::read_dir(&self.config_dir) {
Ok(entries) => entries,
Err(_) => return Ok(reloaded), };
for entry in entries {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml")
|| path.extension().and_then(|s| s.to_str()) == Some("yml")
{
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(_) => continue, };
if let Ok(modified) = metadata.modified() {
let needs_reload = match self.file_times.get(&path) {
Some(last_modified) => modified > *last_modified,
None => true, };
if needs_reload {
match self.load_pack(&path) {
Ok(()) => {
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
reloaded.push(filename);
tracing::info!("Reloaded behavior pack: {}", path.display());
}
Err(e) => {
tracing::error!(
"Failed to reload behavior pack {}: {}",
path.display(),
e
);
}
}
}
}
}
}
Ok(reloaded)
}
pub fn poll_interval(&self) -> Duration {
self.poll_interval
}
pub fn set_poll_interval(&mut self, interval: Duration) {
self.poll_interval = interval;
}
pub fn all_packs(&self) -> &HashMap<String, BehaviorPack> {
&self.packs
}
}
impl BehaviorPack {
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(anyhow::anyhow!("Behavior pack name cannot be empty"));
}
match self.mode {
BehaviorMode::Strict => {
if !self.enable.atoms.is_empty() {
return Err(anyhow::anyhow!(
"Strict mode cannot enable direct atom usage, but {} atoms were enabled",
self.enable.atoms.len()
));
}
}
BehaviorMode::Explore => {
if let Some(ref atom_guards) = self.guards.atoms {
if !atom_guards.require_justification {
tracing::warn!(
"Explore mode behavior pack '{}' does not require justification for atom usage",
self.name
);
}
}
}
BehaviorMode::Shadow => {
}
}
if let Some(ref atom_guards) = self.guards.atoms {
if atom_guards.default_max_bytes == 0 {
return Err(anyhow::anyhow!("default_max_bytes cannot be zero"));
}
if atom_guards.default_max_bytes > 100 * 1024 * 1024 {
tracing::warn!(
"Large default_max_bytes ({} bytes) in behavior pack '{}'",
atom_guards.default_max_bytes,
self.name
);
}
}
if let Some(ref playbook_guards) = self.guards.playbooks {
if playbook_guards.max_steps == 0 {
return Err(anyhow::anyhow!("max_steps cannot be zero"));
}
if playbook_guards.max_steps > 100 {
tracing::warn!(
"Large max_steps ({}) in behavior pack '{}'",
playbook_guards.max_steps,
self.name
);
}
}
for (cap_name, params) in &self.params {
if !params.is_object() {
return Err(anyhow::anyhow!(
"Parameters for capability '{}' must be a JSON object, got: {:?}",
cap_name,
params
));
}
}
Ok(())
}
pub fn is_atom_enabled(&self, atom_name: &str) -> bool {
self.enable.atoms.contains(&atom_name.to_string())
}
pub fn is_macro_enabled(&self, macro_name: &str) -> bool {
self.enable.macros.contains(¯o_name.to_string())
}
pub fn is_playbook_enabled(&self, playbook_name: &str) -> bool {
self.enable.playbooks.contains(&playbook_name.to_string())
}
pub fn get_params(&self, capability_name: &str) -> Option<&serde_json::Value> {
self.params.get(capability_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_behavior_pack_validation() {
let pack = BehaviorPack {
name: "test-pack".to_string(),
mode: BehaviorMode::Strict,
enable: EnabledCapabilities {
atoms: vec![], macros: vec!["test.macro".to_string()],
playbooks: vec!["test.playbook".to_string()],
},
params: HashMap::new(),
guards: GuardConfig::default(),
};
assert!(pack.validate().is_ok());
}
#[test]
fn test_strict_mode_validation_fails_with_atoms() {
let pack = BehaviorPack {
name: "test-pack".to_string(),
mode: BehaviorMode::Strict,
enable: EnabledCapabilities {
atoms: vec!["fs.read.v1".to_string()], macros: vec![],
playbooks: vec![],
},
params: HashMap::new(),
guards: GuardConfig::default(),
};
assert!(pack.validate().is_err());
}
#[test]
fn test_behavior_pack_manager() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut manager = BehaviorPackManager::new(temp_dir.path());
let pack_content = r#"
name: "test-pack"
mode: strict
enable:
atoms: []
macros: ["test.macro"]
playbooks: ["test.playbook"]
params: {}
guards:
atoms:
default_max_bytes: 1048576
require_justification: true
"#;
let pack_path = temp_dir.path().join("test-pack.yaml");
std::fs::write(&pack_path, pack_content)?;
manager.load_all()?;
assert!(manager.get_pack("test-pack").is_some());
assert_eq!(manager.list_packs(), vec!["test-pack"]);
Ok(())
}
}