envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Developer-migration operations: shell preexec hook installation,
//! shell-history scan, and the runtime preexec handler invoked by the
//! installed bash/zsh/fish snippet.
//!
//! Re-exported from [`super`] so existing call sites resolve unchanged.

use std::path::{Path, PathBuf};

use crate::error::Error;

/// Re-export the migration types so CLI commands can import them via
/// `envseal::ops::*` without crossing the boundary into the migration
/// module directly. Keeps `boundary_ops_only` happy.
pub use crate::migration::history::{HistoryFinding, HistoryKind};
pub use crate::migration::preexec::{Confidence, DetectedSecret};
pub use crate::migration::shell_hooks::{InstallReport, Shell};

/// Detect secrets in a single shell command line.
///
/// Wraps [`crate::migration::preexec::detect_in_command`] so command
/// modules don't have to depend on the `migration` module directly.
pub fn detect_secrets_in_command(cmd: &str) -> Vec<DetectedSecret> {
    crate::migration::preexec::detect_in_command(cmd)
}

/// Install (or refresh) the bash/zsh/fish preexec hook in the user's
/// shell rc file.
///
/// # Errors
/// Returns whatever [`crate::migration::shell_hooks::install`] returns.
pub fn install_shell_hook(shell: Shell, rc_path: &Path) -> Result<InstallReport, Error> {
    crate::migration::shell_hooks::install(shell, rc_path)
}

/// Remove the envseal preexec block from `rc_path`. Returns `true` if
/// a block was removed, `false` if no block was present (or the file
/// did not exist).
pub fn uninstall_shell_hook(rc_path: &Path) -> Result<bool, Error> {
    crate::migration::shell_hooks::uninstall(rc_path)
}

/// Print the raw hook snippet for a given shell. Useful for power
/// users who want to install it via their own dotfile manager.
#[must_use]
pub fn shell_hook_snippet(shell: Shell) -> &'static str {
    shell.snippet()
}

/// Best-effort detection of the user's interactive shell.
#[must_use]
pub fn detect_user_shell() -> Option<Shell> {
    crate::migration::shell_hooks::detect_user_shell()
}

/// Default rc-file path for `shell` under `$HOME`.
#[must_use]
pub fn shell_default_rc_path(shell: Shell) -> Option<PathBuf> {
    shell.default_rc_path()
}

/// Convert an environment-variable name (e.g. `OPENAI_API_KEY`) to
/// the canonical kebab-case vault secret name (`openai-api-key`).
/// Re-export of [`crate::file::parser::env_var_to_secret_name`] so
/// CLI command files can call it without crossing the `ops` boundary.
#[must_use]
pub fn env_var_to_secret_name(env_var: &str) -> String {
    crate::file::parser::env_var_to_secret_name(env_var)
}

/// Scan all default-location shell history files for previously-typed
/// secrets. Missing files are silently skipped.
///
/// # Errors
/// Returns `Error::StorageIo` for any read failure on a file that
/// exists.
pub fn scan_shell_history() -> Result<Vec<HistoryFinding>, Error> {
    crate::migration::history::scan_all_default()
}

/// Result of one `__preexec` invocation — what the hook handler did.
#[derive(Debug, Clone)]
pub struct PreexecOutcome {
    /// Number of high-confidence detections found in the command.
    pub detected: usize,
    /// Number of those secrets newly stored in the vault.
    pub stored: usize,
    /// Number of detections the user marked "don't ask again".
    pub skipped: usize,
}

/// Path to the persistent "don't ask again" list used by `__preexec`.
/// Co-located with the vault, security config, and audit log.
fn preexec_skip_path() -> Option<PathBuf> {
    Some(super::vault_root().ok()?.join("preexec-skip.txt"))
}

fn preexec_skip_load() -> std::collections::BTreeSet<String> {
    let Some(p) = preexec_skip_path() else {
        return std::collections::BTreeSet::new();
    };
    let Ok(text) = std::fs::read_to_string(&p) else {
        return std::collections::BTreeSet::new();
    };
    text.lines()
        .filter_map(|l| {
            let t = l.trim();
            if t.is_empty() || t.starts_with('#') {
                None
            } else {
                Some(t.to_string())
            }
        })
        .collect()
}

fn preexec_skip_record(fingerprint: &str) -> Result<(), Error> {
    use std::io::Write as _;
    let Some(p) = preexec_skip_path() else {
        return Ok(());
    };
    if let Some(parent) = p.parent() {
        std::fs::create_dir_all(parent).map_err(Error::StorageIo)?;
    }
    crate::guard::verify_not_symlink(&p)?;
    let mut f = {
        let mut opts = std::fs::OpenOptions::new();
        opts.create(true).append(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            opts.mode(0o600)
                .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
        }
        opts.open(&p).map_err(Error::StorageIo)?
    };
    writeln!(f, "{fingerprint}").map_err(Error::StorageIo)?;
    Ok(())
}

/// Compute a stable fingerprint for the (`env_var`, value) pair so the
/// "don't ask again" list doesn't have to store the plaintext value.
fn preexec_fingerprint(env_var: &str, value: &str) -> String {
    use sha2::{Digest, Sha256};
    use std::fmt::Write as _;
    let mut h = Sha256::new();
    h.update(env_var.as_bytes());
    h.update([0u8]);
    h.update(value.as_bytes());
    let digest = h.finalize();
    // First 16 hex chars is plenty — this is a "did we already ask?"
    // memoization key, not a security boundary.
    let mut out = String::with_capacity(16);
    for b in &digest[..8] {
        let _ = write!(out, "{b:02x}");
    }
    out
}

/// Handle one shell `preexec` invocation. Parses `cmd` for API keys,
/// looks up each high-confidence detection against the skip list,
/// pops a GUI prompt for new ones, and stores accepted secrets in the
/// vault.
///
/// Designed to be invoked in the background by the bash/zsh/fish hook
/// snippets (output is silenced by the snippet, all user interaction
/// is via the platform GUI dialog).
///
/// # Errors
/// Returns `Error::CryptoFailure` if the vault cannot be opened. I/O
/// errors on the skip list are non-fatal.
pub fn handle_preexec_command(cmd: &str) -> Result<PreexecOutcome, Error> {
    let mut outcome = PreexecOutcome {
        detected: 0,
        stored: 0,
        skipped: 0,
    };

    let detections: Vec<_> = detect_secrets_in_command(cmd)
        .into_iter()
        .filter(|d| d.confidence == Confidence::High)
        .collect();
    if detections.is_empty() {
        return Ok(outcome);
    }
    outcome.detected = detections.len();

    let skip = preexec_skip_load();

    for det in detections {
        let fp = preexec_fingerprint(&det.env_var, &det.value);
        if skip.contains(&fp) {
            outcome.skipped += 1;
            continue;
        }

        let provider_label = det.provider.map(|p| format!(" ({p})")).unwrap_or_default();
        let secret_name = crate::file::parser::env_var_to_secret_name(&det.env_var);
        let prompt_message = format!(
            "envseal detected an API key{provider_label} in your shell command:\n\n  \
             {env_var}={preview}\n\nStore it as '{secret_name}' so you don't have to paste it again?\n\
             From now on, run:  envseal inject {secret_name}={env_var} -- <cmd>",
            env_var = det.env_var,
            preview = redact_for_prompt(&det.value)
        );

        match crate::gui::preexec_capture_prompt(&prompt_message)? {
            crate::gui::PreexecChoice::Store => {
                let vault = crate::vault::Vault::open_default()?;
                if let Err(e) = vault.store(&secret_name, det.value.as_bytes(), false) {
                    let _ =
                        crate::audit::log_required(&crate::audit::AuditEvent::SecretStoreFailed {
                            name: secret_name.clone(),
                            reason: e.to_string(),
                        });
                    continue;
                }
                let _ = crate::audit::log_required(&crate::audit::AuditEvent::SecretStored {
                    name: secret_name.clone(),
                });
                outcome.stored += 1;
            }
            crate::gui::PreexecChoice::Skip => {}
            crate::gui::PreexecChoice::DontAskAgain => {
                let _ = preexec_skip_record(&fp);
                outcome.skipped += 1;
            }
        }
    }

    Ok(outcome)
}

fn redact_for_prompt(value: &str) -> String {
    if value.len() <= 8 {
        return "****".to_string();
    }
    let head: String = value.chars().take(4).collect();
    let tail: String = value
        .chars()
        .rev()
        .take(4)
        .collect::<String>()
        .chars()
        .rev()
        .collect();
    format!("{head}{tail}")
}