use anyhow::{Context, Result};
use starlark::environment::{FrozenModule, Globals, GlobalsBuilder, Module};
use starlark::eval::Evaluator;
use starlark::starlark_module;
use starlark::syntax::{AstModule, Dialect};
use starlark::values::none::NoneType;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
Allow,
Prompt(String),
Deny(String),
}
pub struct PolicyEngine {
policies: Vec<FrozenModule>,
policy_dir: PathBuf,
}
impl PolicyEngine {
pub fn new() -> Result<Self> {
let policy_dir = Self::default_policy_dir();
let mut engine = Self {
policies: Vec::new(),
policy_dir: policy_dir.clone(),
};
if policy_dir.exists() {
engine.load_policies()?;
} else {
log::info!(
"Policy directory {:?} does not exist, using defaults",
policy_dir
);
}
Ok(engine)
}
pub fn default_policy_dir() -> PathBuf {
perspt_core::paths::resolve_policy_dir()
.or_else(perspt_core::paths::policy_dir)
.unwrap_or_else(|| PathBuf::from(".").join(".perspt").join("rules"))
}
pub fn load_policies(&mut self) -> Result<()> {
if !self.policy_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(&self.policy_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "star") {
match self.load_policy_file(&path) {
Ok(module) => {
self.policies.push(module);
log::info!("Loaded policy: {:?}", path);
}
Err(e) => {
log::warn!("Failed to load policy {:?}: {}", path, e);
}
}
}
}
log::info!("Loaded {} policies", self.policies.len());
Ok(())
}
fn load_policy_file(&self, path: &Path) -> Result<FrozenModule> {
let content = std::fs::read_to_string(path)
.context(format!("Failed to read policy file: {:?}", path))?;
let ast = AstModule::parse(path.to_string_lossy().as_ref(), content, &Dialect::Standard)
.map_err(|e| anyhow::anyhow!("Parse error: {}", e))?;
let globals = Self::create_globals();
let module = Module::new();
{
let mut eval = Evaluator::new(&module);
eval.eval_module(ast, &globals)
.map_err(|e| anyhow::anyhow!("Eval error: {}", e))?;
}
Ok(module.freeze()?)
}
fn create_globals() -> Globals {
#[starlark_module]
fn policy_builtins(builder: &mut GlobalsBuilder) {
fn matches_pattern(command: &str, pattern: &str) -> anyhow::Result<bool> {
Ok(command.contains(pattern))
}
fn log_policy(message: &str) -> anyhow::Result<NoneType> {
log::info!("[Policy] {}", message);
Ok(NoneType)
}
}
GlobalsBuilder::standard().with(policy_builtins).build()
}
pub fn evaluate(&self, command: &str) -> PolicyDecision {
if self.policies.is_empty() {
return self.default_policy(command);
}
self.default_policy(command)
}
fn default_policy(&self, command: &str) -> PolicyDecision {
let dangerous_patterns = ["rm -rf", "sudo", "chmod 777", "> /dev/", "mkfs", "dd if="];
for pattern in &dangerous_patterns {
if command.contains(pattern) {
return PolicyDecision::Deny(format!(
"Command contains dangerous pattern: {}",
pattern
));
}
}
let network_patterns = ["curl", "wget", "nc ", "ssh ", "scp "];
for pattern in &network_patterns {
if command.contains(pattern) {
return PolicyDecision::Prompt(format!(
"Command requires network access: {}",
command
));
}
}
if command.contains("git push") || command.contains("git force") {
return PolicyDecision::Prompt("Git push operation requires confirmation".to_string());
}
PolicyDecision::Allow
}
pub fn is_safe(&self, command: &str) -> bool {
matches!(self.evaluate(command), PolicyDecision::Allow)
}
}
impl Default for PolicyEngine {
fn default() -> Self {
Self::new().unwrap_or_else(|_| Self {
policies: Vec::new(),
policy_dir: PathBuf::from("."),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_policy_allows_safe_commands() {
let engine = PolicyEngine::default();
assert!(matches!(
engine.evaluate("cargo build"),
PolicyDecision::Allow
));
assert!(matches!(engine.evaluate("ls -la"), PolicyDecision::Allow));
}
#[test]
fn test_default_policy_denies_dangerous() {
let engine = PolicyEngine::default();
assert!(matches!(
engine.evaluate("rm -rf /"),
PolicyDecision::Deny(_)
));
assert!(matches!(
engine.evaluate("sudo rm file"),
PolicyDecision::Deny(_)
));
}
#[test]
fn test_default_policy_prompts_network() {
let engine = PolicyEngine::default();
assert!(matches!(
engine.evaluate("curl https://example.com"),
PolicyDecision::Prompt(_)
));
}
}