agentlog 0.1.2

CLI flight recorder for AI coding agents - capture, store, and replay AI sessions
Documentation
use anyhow::Result;
use colored::Colorize;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConfig {
    pub enabled: bool,
    pub default_action: PolicyAction,
    pub rules: Vec<PolicyRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
    pub name: String,
    pub description: String,
    pub pattern: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub regex: Option<String>,
    pub action: PolicyAction,
    pub enabled: bool,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PolicyAction {
    Block,
    Warn,
    RequireConfirmation,
    Allow,
}

impl Default for PolicyConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            default_action: PolicyAction::Allow,
            rules: default_rules(),
        }
    }
}

fn default_rules() -> Vec<PolicyRule> {
    vec![
        PolicyRule {
            name: "auth_files".to_string(),
            description: "Authentication and authorization files".to_string(),
            pattern: "**/auth/**/*".to_string(),
            regex: None,
            action: PolicyAction::Block,
            enabled: true,
        },
        PolicyRule {
            name: "env_files".to_string(),
            description: "Environment files with secrets".to_string(),
            pattern: "**/.env*".to_string(),
            regex: None,
            action: PolicyAction::Block,
            enabled: true,
        },
        PolicyRule {
            name: "terraform_state".to_string(),
            description: "Terraform state files".to_string(),
            pattern: "**/*.tfstate*".to_string(),
            regex: None,
            action: PolicyAction::Block,
            enabled: true,
        },
        PolicyRule {
            name: "db_migrations".to_string(),
            description: "Database migration files".to_string(),
            pattern: "**/migrations/**/*".to_string(),
            regex: None,
            action: PolicyAction::RequireConfirmation,
            enabled: true,
        },
        PolicyRule {
            name: "encryption_keys".to_string(),
            description: "Encryption key files".to_string(),
            pattern: "**/*.key".to_string(),
            regex: None,
            action: PolicyAction::Block,
            enabled: true,
        },
    ]
}

pub struct PolicyEngine {
    config: PolicyConfig,
    compiled_patterns: Vec<(PolicyRule, Regex)>,
}

#[derive(Debug)]
pub struct Violation {
    pub rule: PolicyRule,
    pub file_path: String,
    pub action: PolicyAction,
}

impl PolicyEngine {
    pub fn new(config: PolicyConfig) -> Result<Self> {
        let mut compiled_patterns = Vec::new();

        for rule in &config.rules {
            if !rule.enabled {
                continue;
            }

            let pattern = if let Some(ref regex) = rule.regex {
                regex.clone()
            } else {
                glob_to_regex(&rule.pattern)?
            };

            match Regex::new(&pattern) {
                Ok(regex) => {
                    compiled_patterns.push((rule.clone(), regex));
                }
                Err(e) => {
                    eprintln!("Warning: Invalid pattern '{}': {}", rule.pattern, e);
                    continue;
                }
            }
        }

        Ok(Self {
            config,
            compiled_patterns,
        })
    }

    pub fn check_file(&self, file_path: &Path) -> Option<Violation> {
        if !self.config.enabled {
            return None;
        }

        let path_str = file_path.to_string_lossy();

        for (rule, regex) in &self.compiled_patterns {
            if regex.is_match(&path_str) {
                return Some(Violation {
                    rule: rule.clone(),
                    file_path: path_str.to_string(),
                    action: rule.action,
                });
            }
        }

        None
    }
}

fn glob_to_regex(glob: &str) -> Result<String> {
    let mut regex = String::new();

    // Handle patterns that start with **/ specially - they should match at any depth
    // including at the root
    if let Some(rest) = glob.strip_prefix("**/") {
        regex.push_str("(^|.*/)");
        regex.push_str(&convert_glob_pattern(rest)?);
    } else if let Some(rest) = glob.strip_prefix("**") {
        regex.push_str(".*");
        regex.push_str(&convert_glob_pattern(rest)?);
    } else {
        regex.push('^');
        regex.push_str(&convert_glob_pattern(glob)?);
    }

    regex.push('$');
    Ok(regex)
}

fn convert_glob_pattern(pattern: &str) -> Result<String> {
    let mut regex = String::new();
    let mut chars = pattern.chars().peekable();

    while let Some(ch) = chars.next() {
        match ch {
            '*' => {
                // Check if this is a double asterisk
                if chars.peek() == Some(&'*') {
                    chars.next(); // Consume the second *
                                  // Check if followed by /
                    if chars.peek() == Some(&'/') {
                        chars.next(); // Consume the /
                        regex.push_str(".*"); // **/ matches any path segment
                    } else {
                        regex.push_str(".*"); // ** matches anything including /
                    }
                } else {
                    regex.push_str("[^/]*"); // * matches anything except /
                }
            }
            '?' => regex.push('.'),
            '.' => regex.push_str(r"\."),
            '/' => regex.push('/'),
            _ => {
                if "+()^$|[]{}".contains(ch) {
                    regex.push('\\');
                }
                regex.push(ch);
            }
        }
    }

    Ok(regex)
}

impl Violation {
    pub fn format_message(&self) -> String {
        let action_str = match self.action {
            PolicyAction::Block => "BLOCKED".red().bold().to_string(),
            PolicyAction::Warn => "WARNING".yellow().bold().to_string(),
            PolicyAction::RequireConfirmation => "REQUIRES CONFIRMATION".cyan().bold().to_string(),
            PolicyAction::Allow => "ALLOWED".green().to_string(),
        };

        format!(
            "{}: {}\n  Rule: {}\n  Description: {}",
            action_str, self.file_path, self.rule.name, self.rule.description
        )
    }
}