openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
/// `openlatch uninstall` command handler.
///
/// Removes OpenLatch hooks from the agent config and stops the daemon.
/// Optionally deletes the openlatch data directory with `--purge`.
///
/// SECURITY (T-02-08): Only deletes openlatch_dir(), never follows symlinks outside it.
/// Requires --yes or interactive confirmation.
use std::io::{IsTerminal, Write};

use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::UninstallArgs;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG};
use crate::hooks;

/// Run the `openlatch uninstall` command.
///
/// Steps:
/// 1. Confirm (unless --yes or non-interactive)
/// 2. Remove hooks from settings.json
/// 3. Stop the daemon
/// 4. If --purge, delete the openlatch data directory
///
/// # Errors
///
/// Returns an error if hook removal or directory deletion fails.
pub fn run_uninstall(args: &UninstallArgs, output: &OutputConfig) -> Result<(), OlError> {
    // Step 1: Confirm (T-02-08: require explicit confirmation)
    if !args.yes {
        let is_tty = std::io::stdout().is_terminal();
        if is_tty && output.format == OutputFormat::Human {
            let purge_note = if args.purge {
                " and DELETE all OpenLatch data"
            } else {
                ""
            };
            eprint!("This will remove OpenLatch hooks{purge_note} and stop the daemon. Continue? [y/N] ");
            let _ = std::io::stderr().flush();

            let mut line = String::new();
            let _ = std::io::stdin().read_line(&mut line);
            if !line.trim().eq_ignore_ascii_case("y") {
                output.print_info("Aborted.");
                return Ok(());
            }
        }
    }

    // Step 2: Remove hooks from agent settings
    match hooks::detect_agent() {
        Ok(agent) => {
            match hooks::remove_hooks(&agent) {
                Ok(()) => {
                    let settings_path = match &agent {
                        hooks::DetectedAgent::ClaudeCode { settings_path, .. } => {
                            settings_path.display().to_string()
                        }
                    };
                    output.print_step(&format!("Hooks removed from {settings_path}"));
                }
                Err(e) => {
                    // Non-fatal: log but continue
                    output.print_info(&format!(
                        "Warning: could not remove hooks: {} ({})",
                        e.message, e.code
                    ));
                }
            }
        }
        Err(_) => {
            // Agent not found — hooks might not be installed, that's fine
            output.print_info("Agent not detected — skipping hook removal");
        }
    }

    // Step 2.5: Tear down OS supervision BEFORE stopping the daemon.
    // If we stopped the daemon first, the supervisor would immediately restart
    // it. Best-effort — never block uninstall.
    if let Some(supervisor) = crate::supervision::select_supervisor() {
        match supervisor.uninstall() {
            Ok(()) => output.print_step("Supervision removed"),
            Err(e) => output.print_info(&format!(
                "Warning: could not remove supervision: {} ({})",
                e.message, e.code
            )),
        }
    }
    // Best-effort: persist mode=disabled so post-uninstall state reflects reality.
    let config_path = config::openlatch_dir().join("config.toml");
    if config_path.exists() {
        let _ = config::persist_supervision_state(
            &config_path,
            &crate::supervision::SupervisionMode::Disabled,
            &crate::supervision::SupervisorKind::None,
            Some("uninstalled"),
        );
    }

    // Step 3: Stop the daemon
    lifecycle::run_stop(output)?;

    // Step 4: Purge data directory if --purge (T-02-08: only openlatch_dir(), no symlink following)
    if args.purge {
        let ol_dir = config::openlatch_dir();

        // SECURITY: Canonicalize and verify the directory is within expected bounds
        // before deletion to prevent path traversal.
        if ol_dir.exists() {
            let canonical = std::fs::canonicalize(&ol_dir).map_err(|e| {
                OlError::new(
                    ERR_INVALID_CONFIG,
                    format!("Cannot canonicalize openlatch directory: {e}"),
                )
            })?;

            // Sanity check: the canonical path should end with "openlatch" or ".openlatch"
            // This prevents accidental deletion of the wrong directory.
            let dir_name = canonical.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if dir_name != "openlatch" && dir_name != ".openlatch" {
                return Err(OlError::new(
                    ERR_INVALID_CONFIG,
                    format!(
                        "Unexpected openlatch directory name '{}' — refusing to delete for safety",
                        canonical.display()
                    ),
                )
                .with_suggestion("If this is correct, manually delete the directory."));
            }

            std::fs::remove_dir_all(&canonical).map_err(|e| {
                OlError::new(
                    ERR_INVALID_CONFIG,
                    format!(
                        "Cannot delete openlatch directory '{}': {e}",
                        canonical.display()
                    ),
                )
                .with_suggestion("Check that you have write permission.")
            })?;

            output.print_step(&format!("Data directory removed: {}", canonical.display()));
        } else {
            output.print_info("Data directory does not exist — nothing to purge");
        }
    }

    // Telemetry: emit uninstalled. Only one supported agent today, so the
    // count is 1 if removal was attempted, 0 otherwise. This is best-effort —
    // we do not retain the prior detection result here.
    crate::telemetry::capture_global(crate::telemetry::Event::uninstalled(1));

    if output.format == OutputFormat::Json {
        let json = serde_json::json!({
            "status": "ok",
            "purged": args.purge,
        });
        output.print_json(&json);
    } else if !output.quiet {
        eprintln!();
        eprintln!("OpenLatch uninstalled successfully.");
        if args.purge {
            eprintln!("All data removed.");
        } else {
            eprintln!(
                "Data directory preserved at {}. Use --purge to remove it.",
                config::openlatch_dir().display()
            );
        }
    }

    Ok(())
}