roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
//! Mechanic/health checks and security config migration.

use std::path::Path;

use super::{HygieneFn, colors, icons};
use crate::cli::{CRT_DRAW_MS, theme};

pub(super) fn run_mechanic_checks_maintenance(config_path: &str, hygiene_fn: Option<&HygieneFn>) {
    let (_, _, _, _, _, _, _, _, _) = colors();
    let (OK, _, WARN, DETAIL, _) = icons();

    if let Some(hfn) = hygiene_fn {
        match hfn(config_path) {
            Ok(Some((changed, subagent, cron_payload, cron_disabled))) => {
                if changed > 0 || subagent > 0 || cron_payload > 0 || cron_disabled > 0 {
                    println!(
                        "    {OK} State hygiene: {changed} session(s), {subagent} sub-agent(s), {cron_payload} cron payload(s), {cron_disabled} disabled cron(s) cleaned"
                    );
                } else {
                    println!("    {OK} State hygiene: all clean");
                }
            }
            Ok(None) => {
                println!("    {OK} State hygiene: no database to check");
            }
            Err(e) => {
                println!("    {WARN} State hygiene check failed: {e}");
                println!("    {DETAIL} Run `roboticus mechanic --repair` to investigate.");
            }
        }
    }
}

pub(super) fn apply_removed_legacy_config_migration(
    config_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let path = Path::new(config_path);
    if !path.exists() {
        return Ok(());
    }
    let raw = std::fs::read_to_string(path)?;
    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");

    // Remove deprecated `allowed_models` key from [security] if present.
    let has_allowed_models = normalized
        .lines()
        .any(|line| line.trim().starts_with("allowed_models"));
    if !has_allowed_models {
        return Ok(());
    }

    let filtered: Vec<&str> = normalized
        .lines()
        .filter(|line| !line.trim().starts_with("allowed_models"))
        .collect();
    let result = filtered.join("\n");

    let tmp = path.with_extension("toml.tmp");
    std::fs::write(&tmp, &result)?;
    std::fs::rename(&tmp, path)?;

    let (_, _, _, _, _, _, _, _, _) = colors();
    let (_, _, _, DETAIL, _) = icons();
    println!("    {DETAIL} Removed deprecated `allowed_models` from config");
    Ok(())
}

// ── Security config migration ────────────────────────────────

/// Detect pre-RBAC config files (missing `[security]` section) and auto-append
/// the section with explicit defaults. Also prints a breaking-change warning
/// about the new deny-by-default behavior for empty channel allow-lists.
pub(super) fn apply_security_config_migration(
    config_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let path = Path::new(config_path);
    if !path.exists() {
        return Ok(());
    }

    let raw = std::fs::read_to_string(path)?;
    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");

    let has_security = normalized.lines().any(|line| line.trim() == "[security]");

    if has_security {
        return Ok(());
    }

    let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
    let (_, ERR, WARN, DETAIL, _) = icons();

    println!();
    println!("  {ERR} {BOLD}SECURITY MODEL CHANGE{RESET}");
    println!();
    println!(
        "    Empty channel allow-lists now {BOLD}DENY all messages{RESET} (previously allowed all)."
    );
    println!(
        "    This is a critical security fix — your agent was previously open to the internet."
    );
    println!();

    if let Ok(config) = roboticus_core::RoboticusConfig::from_file(path) {
        let channels_status = describe_channel_allowlists(&config);
        if !channels_status.is_empty() {
            println!("    Your current configuration:");
            for line in &channels_status {
                println!("      {line}");
            }
            println!();
        }

        if config.channels.trusted_sender_ids.is_empty() {
            println!("    {WARN} trusted_sender_ids = [] (no Creator-level users configured)");
            println!();
        }
    }

    println!("    Run {BOLD}roboticus mechanic --repair{RESET} for guided security setup.");
    println!();

    let security_section = r#"
# Security: Claim-based RBAC authority resolution.
# See `roboticus mechanic` for guided configuration.
[security]
deny_on_empty_allowlist = true  # empty allow-lists deny all messages (secure default)
allowlist_authority = "Peer"     # allow-listed senders get Peer authority
trusted_authority = "Creator"    # trusted_sender_ids get Creator authority
api_authority = "Creator"        # HTTP API callers get Creator authority
threat_caution_ceiling = "External"  # threat-flagged inputs are capped at External
"#;

    let backup = path.with_extension("toml.bak");
    if !backup.exists() {
        std::fs::copy(path, &backup)?;
    }

    let mut content = normalized;
    content.push_str(security_section);

    let tmp = path.with_extension("toml.tmp");
    std::fs::write(&tmp, &content)?;
    std::fs::rename(&tmp, path)?;

    println!("    {DETAIL} Added [security] section to {config_path} (backup: .toml.bak)");
    println!();

    Ok(())
}

/// Produce human-readable status lines for each configured channel's allow-list.
fn describe_channel_allowlists(config: &roboticus_core::RoboticusConfig) -> Vec<String> {
    let mut lines = Vec::new();

    if let Some(ref tg) = config.channels.telegram {
        if tg.allowed_chat_ids.is_empty() {
            lines.push("Telegram: allowed_chat_ids = [] (was: open to all → now: deny all)".into());
        } else {
            lines.push(format!(
                "Telegram: {} chat ID(s) configured",
                tg.allowed_chat_ids.len()
            ));
        }
    }

    if let Some(ref dc) = config.channels.discord {
        if dc.allowed_guild_ids.is_empty() {
            lines.push("Discord: allowed_guild_ids = [] (was: open to all → now: deny all)".into());
        } else {
            lines.push(format!(
                "Discord: {} guild ID(s) configured",
                dc.allowed_guild_ids.len()
            ));
        }
    }

    if let Some(ref wa) = config.channels.whatsapp {
        if wa.allowed_numbers.is_empty() {
            lines.push("WhatsApp: allowed_numbers = [] (was: open to all → now: deny all)".into());
        } else {
            lines.push(format!(
                "WhatsApp: {} number(s) configured",
                wa.allowed_numbers.len()
            ));
        }
    }

    if let Some(ref sig) = config.channels.signal {
        if sig.allowed_numbers.is_empty() {
            lines.push("Signal: allowed_numbers = [] (was: open to all → now: deny all)".into());
        } else {
            lines.push(format!(
                "Signal: {} number(s) configured",
                sig.allowed_numbers.len()
            ));
        }
    }

    if !config.channels.email.allowed_senders.is_empty() {
        lines.push(format!(
            "Email: {} sender(s) configured",
            config.channels.email.allowed_senders.len()
        ));
    } else if config.channels.email.enabled {
        lines.push("Email: allowed_senders = [] (was: open to all → now: deny all)".into());
    }

    lines
}

// ── FIRMWARE.toml rules schema migration ──────────────────────────

/// Migrate FIRMWARE.toml from legacy `[[rules]]` array format to the
/// canonical `[rules]` table format. The legacy format used an array
/// of `{type, rule}` entries; the canonical format uses a free-form
/// table with arbitrary keys.
///
/// Migration: each `[[rules]]` entry with `type = "must"` and
/// `rule = "..."` becomes a key under `[rules]`: `must_N = "..."`.
/// `must_not` entries become `must_not_N = "..."`.
pub(super) fn migrate_firmware_rules(
    workspace_path: &Path,
) -> Result<bool, Box<dyn std::error::Error>> {
    let firmware_path = workspace_path.join("FIRMWARE.toml");
    if !firmware_path.exists() {
        return Ok(false);
    }

    let raw = std::fs::read_to_string(&firmware_path)?;

    // Only migrate if the file contains [[rules]] (array-of-tables syntax)
    if !raw.contains("[[rules]]") {
        return Ok(false);
    }

    // Parse the raw TOML to extract the rules array
    let parsed: toml::Value = toml::from_str(&raw)?;
    let rules = match parsed.get("rules").and_then(|v| v.as_array()) {
        Some(arr) if !arr.is_empty() => arr,
        _ => return Ok(false),
    };

    // Build replacement [rules] table from the array entries
    let mut table_lines = Vec::new();
    table_lines.push("[rules]".to_string());
    let mut must_idx = 0u32;
    let mut must_not_idx = 0u32;
    let mut boundary_idx = 0u32;

    for entry in rules {
        let rule_type = entry
            .get("type")
            .and_then(|v| v.as_str())
            .unwrap_or("boundary");
        let rule_text = entry.get("rule").and_then(|v| v.as_str()).unwrap_or("");

        if rule_text.is_empty() {
            continue;
        }

        let key = match rule_type {
            "must" => {
                must_idx += 1;
                format!("must_{must_idx}")
            }
            "must_not" => {
                must_not_idx += 1;
                format!("must_not_{must_not_idx}")
            }
            _ => {
                boundary_idx += 1;
                format!("boundary_{boundary_idx}")
            }
        };

        // Escape the rule text for TOML string
        let escaped = rule_text.replace('\\', "\\\\").replace('"', "\\\"");
        table_lines.push(format!("{key} = \"{escaped}\""));
    }

    // Replace the [[rules]] sections with the [rules] table
    // Remove all [[rules]] blocks and their contents, then append [rules] table
    let mut output_lines = Vec::new();
    let mut in_rules_block = false;

    for line in raw.lines() {
        if line.trim() == "[[rules]]" {
            in_rules_block = true;
            continue;
        }
        if in_rules_block {
            // Still in a [[rules]] entry — skip until next section or empty line
            let trimmed = line.trim();
            if trimmed.is_empty() || (trimmed.starts_with('[') && trimmed != "[[rules]]") {
                in_rules_block = false;
                if trimmed.starts_with('[') {
                    output_lines.push(line.to_string());
                }
            }
            continue;
        }
        output_lines.push(line.to_string());
    }

    // Append the new [rules] table
    output_lines.push(String::new());
    output_lines.extend(table_lines);
    output_lines.push(String::new());

    let result = output_lines.join("\n");
    let tmp = firmware_path.with_extension("toml.tmp");
    std::fs::write(&tmp, &result)?;
    std::fs::rename(&tmp, &firmware_path)?;

    let (_, _, _, DETAIL, _) = icons();
    println!("    {DETAIL} Migrated FIRMWARE.toml rules from [[rules]] array to [rules] table");

    Ok(true)
}