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");
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(())
}
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(())
}
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
}
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)?;
if !raw.contains("[[rules]]") {
return Ok(false);
}
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),
};
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}")
}
};
let escaped = rule_text.replace('\\', "\\\\").replace('"', "\\\"");
table_lines.push(format!("{key} = \"{escaped}\""));
}
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 {
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());
}
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)
}