tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Agent daemon command handlers.
//!
//! Implements `tsafe agent unlock / lock / status` — the background daemon that
//! holds the vault password so interactive unlocking is only required once per
//! session instead of per-command.

use std::io::BufRead;
use std::path::PathBuf;

use anyhow::{Context, Result};
use colored::Colorize;
use serde::Serialize;
use tsafe_cli::cli::AgentAction;
use tsafe_core::{agent, profile};

// ── JSON status contract (ADR-029) ────────────────────────────────────────────

/// Machine-readable agent status payload emitted by `tsafe agent status --json`.
///
/// Schema version "1".  Consumers must check `version` before reading any
/// other field.  Nullable fields are documented in ADR-029.
#[derive(Serialize)]
struct AgentStatusJson {
    /// Schema version.  Always `"1"` in this release.
    version: &'static str,
    /// `true` when the agent socket is present and responds to a Ping.
    agent_running: bool,
    /// `false` only when `agent_running` is `true`.  Means "no live unlocked
    /// session is reachable", not that the vault file itself is locked.
    vault_locked: bool,
    /// Profile name passed to `tsafe --profile` (or the default).
    active_profile: String,
    /// Whether the OS credential store contains a biometric/keyring entry for
    /// `active_profile`.  Reads the keyring; never prompts for biometric UI.
    biometric_enabled: bool,
    /// ISO-8601 absolute session deadline, or null.  Always null in version 1 —
    /// populating it requires a future `AgentRequest::Status` IPC variant.
    session_expires_at: Option<String>,
    /// Seconds until the idle TTL fires, or null.  Always null in version 1.
    idle_ttl_remaining_secs: Option<u64>,
    /// Seconds until the absolute TTL fires, or null.  Always null in version 1.
    absolute_ttl_remaining_secs: Option<u64>,
    /// PID of the running agent, extracted from the pipe/socket path, or null.
    agent_pid: Option<u32>,
}

// ── TTL parsing ───────────────────────────────────────────────────────────────

/// Parse a TTL string like "30m", "1h", "8h", "24h" into seconds.
pub(crate) fn parse_ttl(s: &str) -> Option<u64> {
    if let Some(n) = s.strip_suffix('m') {
        return n.parse::<u64>().ok().map(|v| v * 60);
    }
    if let Some(n) = s.strip_suffix('h') {
        return n.parse::<u64>().ok().map(|v| v * 3600);
    }
    if let Some(n) = s.strip_suffix('s') {
        return n.parse::<u64>().ok();
    }
    s.parse::<u64>().ok()
}

/// Name of the agent executable for this platform (`tsafe-agent` or `tsafe-agent.exe`).
fn tsafe_agent_filename() -> &'static str {
    if cfg!(windows) {
        "tsafe-agent.exe"
    } else {
        "tsafe-agent"
    }
}

/// Find `tsafe-agent`: same directory as this `tsafe` binary first, then `PATH`.
fn resolve_tsafe_agent_binary() -> Result<PathBuf> {
    let name = tsafe_agent_filename();

    if let Ok(path) = std::env::var("TSAFE_AGENT_BIN") {
        let candidate = PathBuf::from(path);
        if candidate.is_file() {
            return Ok(candidate);
        }
    }

    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            let candidate = dir.join(name);
            if candidate.is_file() {
                return Ok(candidate);
            }
        }
    }

    if let Some(hit) = find_on_path(name) {
        return Ok(PathBuf::from(hit));
    }

    let tsafe = std::env::current_exe()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|_| "(could not read tsafe path)".to_string());

    anyhow::bail!(
        "tsafe-agent binary not found.\n\
         \n\
         Looked at TSAFE_AGENT_BIN, next to this tsafe ({tsafe}), and on your PATH for `{name}`.\n\
         \n\
         Install tsafe-agent via the release channel or companion-binary path that ships it for your stack.\n\
         That can be a bundle that places tsafe-agent next to tsafe or a separate companion install that adds it to PATH.\n\
         Development builds: `cargo build -p tsafe-agent` places tsafe-agent in the same\n\
         target/*/debug (or release) directory as tsafe."
    );
}

fn find_on_path(filename: &str) -> Option<String> {
    std::env::var_os("PATH").and_then(|path_var| {
        std::env::split_paths(&path_var).find_map(|dir| {
            let candidate = dir.join(filename);
            if candidate.is_file() {
                candidate.to_str().map(|s| s.to_string())
            } else {
                None
            }
        })
    })
}

#[cfg(target_os = "macos")]
fn applescript_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

// ── Command handlers ──────────────────────────────────────────────────────────

pub(crate) fn cmd_agent(profile: &str, action: AgentAction) -> Result<()> {
    match action {
        AgentAction::Unlock { ttl, absolute_ttl } => cmd_agent_unlock(profile, &ttl, &absolute_ttl),
        AgentAction::Lock => cmd_agent_lock(),
        AgentAction::Status { json } => {
            if json {
                cmd_agent_status_json(profile)
            } else {
                cmd_agent_status_human()
            }
        }
    }
}

fn cmd_agent_unlock(profile: &str, ttl: &str, absolute_ttl: &str) -> Result<()> {
    use std::process::Stdio;

    // Parse both TTLs into seconds.
    let idle_ttl_secs = parse_ttl(ttl)
        .ok_or_else(|| anyhow::anyhow!("invalid idle TTL '{ttl}' — use e.g. 15m, 1h, 4h"))?;
    let absolute_ttl_secs = parse_ttl(absolute_ttl).ok_or_else(|| {
        anyhow::anyhow!("invalid absolute TTL '{absolute_ttl}' — use e.g. 8h, 12h, 24h")
    })?;
    anyhow::ensure!(
        absolute_ttl_secs >= idle_ttl_secs,
        "--absolute-ttl ({absolute_ttl}) must be >= --ttl ({ttl})"
    );

    // Validate the vault exists before prompting.
    let vault_path = profile::vault_path(profile);
    anyhow::ensure!(
        vault_path.exists(),
        "no vault for profile '{profile}'. Create one with: tsafe --profile {profile} init"
    );

    // Generate a random session token using OS-provided entropy.
    let token_hex: String = {
        use rand::RngCore;
        let mut b = [0u8; 32];
        rand::rngs::OsRng.fill_bytes(&mut b);
        b.iter().map(|byte| format!("{byte:02x}")).collect()
    };

    let my_pid = std::process::id();

    // Fail fast if the daemon is missing — avoids a pointless notification + Enter wait.
    let agent_bin = resolve_tsafe_agent_binary()?;

    // Full text is always printed here; OS notifications are best-effort (title/body varies by platform).
    let notify_msg = format!(
        "PID {my_pid} requests vault unlock for profile \"{profile}\". Use this terminal as source of truth; any desktop banner is only a hint."
    );
    eprintln!("{notify_msg}");
    #[cfg(target_os = "windows")]
    {
        let ps_cmd = format!(
            r#"Import-Module BurntToast -ErrorAction SilentlyContinue; New-BurntToastNotification -Text 'tsafe Agent', '{notify_msg}' -AppId 'tsafe' 2>$null; exit 0"#
        );
        let _ = std::process::Command::new("powershell.exe")
            .args(["-NonInteractive", "-Command", &ps_cmd])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn();
    }
    #[cfg(target_os = "macos")]
    {
        // Title + body: some macOS versions show an empty body if only the long string is in one field.
        let title = format!("tsafe · PID {my_pid} · profile {profile}");
        let body = "Approve by entering your vault password in the terminal.";
        let script = format!(
            "display notification \"{}\" with title \"{}\"",
            applescript_escape(body),
            applescript_escape(&title)
        );
        let _ = std::process::Command::new("osascript")
            .args(["-e", &script])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn();
    }
    #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
    {
        let _ = std::process::Command::new("notify-send")
            .args(["tsafe Agent", &notify_msg])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn();
    }

    eprintln!("Enter your vault password here to approve and start the agent.");

    // Spawn the daemon. It acquires the vault password from keychain/env or stdin
    // (we forward our terminal), then prints TSAFE_AGENT_SOCK=<value> to stdout.
    let mut child = std::process::Command::new(&agent_bin)
        .args([
            profile,
            &token_hex,
            &my_pid.to_string(),
            &idle_ttl_secs.to_string(),
            &absolute_ttl_secs.to_string(),
        ])
        .stdout(Stdio::piped())
        .stdin(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn()
        .with_context(|| format!("failed to start {}", agent_bin.display()))?;

    // Read the TSAFE_AGENT_SOCK line from the daemon.
    let stdout = child.stdout.take().expect("piped stdout");
    let mut reader = std::io::BufReader::new(stdout);
    let mut line = String::new();
    reader
        .read_line(&mut line)
        .context("agent did not emit TSAFE_AGENT_SOCK")?;
    let line = line.trim();

    if !line.starts_with(agent::ENV_AGENT_SOCK) {
        anyhow::bail!("agent emitted unexpected output: {line}");
    }

    let sock_val = line
        .split_once('=')
        .map(|(_, v)| v)
        .ok_or_else(|| anyhow::anyhow!("malformed agent output: {line}"))?
        .trim_matches('"');

    // Print eval-able export lines for the user's shell.
    println!("# Run one of the following in your shell to activate the agent:");
    println!("$env:TSAFE_AGENT_SOCK = \"{sock_val}\"  # PowerShell");
    println!("export TSAFE_AGENT_SOCK=\"{sock_val}\"  # bash/zsh");
    Ok(())
}

fn cmd_agent_lock() -> Result<()> {
    agent::send_lock().map_err(|e| anyhow::anyhow!("lock failed: {e}"))?;
    println!("{} Agent session revoked.", "".green());
    Ok(())
}

fn cmd_agent_status_human() -> Result<()> {
    let env_sock = agent::read_agent_sock_env();
    match env_sock.clone().or_else(agent::read_agent_sock) {
        None => {
            println!("No agent running (no TSAFE_AGENT_SOCK and no persisted agent state).");
        }
        Some(sock) => {
            // Try a Ping.
            match agent::ping_agent(&sock) {
                Ok(true) => {
                    if env_sock.is_some() {
                        println!("{} Agent socket is reachable ({})", "".green(), sock);
                    } else {
                        println!(
                            "{} Agent socket is reachable via persisted state ({})",
                            "".green(),
                            sock
                        );
                    }
                    println!(
                        "  Reachability does not confirm that the session was unlocked for the current --profile."
                    );
                }
                Ok(false) => println!("{} Agent pipe found but ping failed.", "".red()),
                Err(e) => println!("{} Agent unreachable: {e}", "".red()),
            }
        }
    }
    Ok(())
}

/// Emit the machine-readable agent status JSON (ADR-029 schema version "1").
fn cmd_agent_status_json(active_profile: &str) -> Result<()> {
    let sock = agent::read_agent_sock_env().or_else(agent::read_agent_sock);

    let (agent_running, agent_pid) = match &sock {
        None => (false, None),
        Some(s) => {
            let pid = parse_agent_pid_from_sock(s);
            let running = matches!(agent::ping_agent(s), Ok(true));
            (running, pid)
        }
    };

    let vault_locked = !agent_running;

    // Biometric check: read keyring without prompting.
    // Feature-gated so it compiles cleanly in non-biometric builds.
    let biometric_enabled = biometric_has_password(active_profile);

    let status = AgentStatusJson {
        version: "1",
        agent_running,
        vault_locked,
        active_profile: active_profile.to_string(),
        biometric_enabled,
        // These three fields require a future AgentRequest::Status IPC variant.
        // Always null in schema version 1.
        session_expires_at: None,
        idle_ttl_remaining_secs: None,
        absolute_ttl_remaining_secs: None,
        agent_pid,
    };

    let json = serde_json::to_string_pretty(&status)
        .context("failed to serialize agent status to JSON")?;
    println!("{json}");
    Ok(())
}

/// Parse the agent PID from the socket/pipe value.
///
/// Windows named pipe: `\\.\pipe\tsafe-agent-{pid}::{token}`
/// Unix socket: `/tmp/tsafe-agent-{pid}.sock::{token}`
fn parse_agent_pid_from_sock(sock: &str) -> Option<u32> {
    // Strip the token suffix first.
    let pipe = agent::parse_agent_sock(sock).map(|(p, _)| p)?;

    // Try to find "tsafe-agent-{digits}" in the path.
    let marker = "tsafe-agent-";
    let idx = pipe.rfind(marker)?;
    let after = &pipe[idx + marker.len()..];
    // On Unix: ends with ".sock"; on Windows: end of string.
    let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
    digits.parse::<u32>().ok()
}

/// Return whether the OS credential store holds an entry for `profile`.
/// On non-biometric builds, always returns `false`.
fn biometric_has_password(profile: &str) -> bool {
    #[cfg(feature = "biometric")]
    {
        tsafe_core::keyring_store::has_password(profile)
    }
    #[cfg(not(feature = "biometric"))]
    {
        let _ = profile;
        false
    }
}