envseal 0.3.5

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! High-level operations — the unified API for all envseal consumers.
//!
//! This module is the single facade that CLI, desktop UI, and MCP
//! consume. Operations split into two categories:
//! - **Passphrase-free**: work with filesystem only (list, check, status)
//! - **Vault-locked**: require an unlocked `Vault` (peek, rename, copy)
//!
//! # Submodules
//!
//! Domain-grouped submodules live alongside this file. Each is
//! re-exported here so the `envseal::ops::*` surface stays flat
//! and stable (LAW 2 — backwards-compatible).
//!
//! - [`admin`] — security tier presets, granular field setters,
//!   detection→policy overrides, and TOTP pairing. The "operator
//!   configures the vault" surface.
//! - [`governance`] — access-policy inspection/revocation and Git
//!   pre-commit hook installation. Vault-level *rules*, distinct
//!   from the "operator manipulates a secret" surface that lives
//!   directly in this file.

pub mod admin;
pub mod ctf;
pub mod dev_migration;
pub mod execution;
pub mod governance;
pub mod lifecycle;
pub mod queries;
pub use admin::*;
pub use ctf::*;
pub use dev_migration::*;
pub use execution::*;
pub use governance::*;
pub use lifecycle::*;
pub use queries::*;

use crate::error::Error;
use crate::vault::Vault;
use crate::{gui, secret_health, security_config};
use std::path::{Path, PathBuf};

// Read-only queries (vault_root, list_*, peek_*, check_*, env_dry_run,
// vault_status, audit_log + their data types) live in `ops::queries`.
// Secret mutations (store_*, revoke_*, copy_secret, rename_secret,
// emergency_revoke_all, rotate_secret, shred_file, import_env_file,
// request_secret_value_default + EmergencyRevokeResult / ImportResult)
// live in `ops::lifecycle`. Both are re-exported above for backwards
// compatibility — every `envseal::ops::<name>` path that worked before
// the split still resolves.

/// Return a health report for secrets in the default vault.
pub fn health_report(max_days: Option<u64>) -> Result<secret_health::HealthReport, Error> {
    let vault = Vault::open_default()?;
    secret_health::health_report(&vault, max_days)
}

// Policy display/revocation and Git hook helpers moved to `ops::governance`.
// Security/TOTP/policy-override administration moved to `ops::admin`.
// Public API preserved by the `pub use governance::*; pub use admin::*;` re-exports above.

// Process-spawning ops moved to `ops::execution`.
// Public API preserved by the `pub use execution::*;` re-export above.

// Re-export GUI threat-assessment types under `ops` so command files
// don't have to depend on the `guard` module directly. The
// `boundary_ops_only` test enforces that command files import only from
// `envseal::ops` / `envseal::error::Error`.
pub use crate::guard::{GuiSecurityReport, GuiThreatLevel, PhysicalPresence};

/// Aggregated diagnostics for `envseal doctor`.
///
/// Holds every probe envseal runs at startup (`guard::*` checks, GUI threat
/// assessment, vault root status). The CLI/desktop renders this; tests can
/// assert against it without parsing free-form text.
#[derive(Debug, Clone)]
pub struct DoctorReport {
    /// Whether `ptrace_scope` is permissive (Linux only). Unset off Linux.
    pub ptrace_scope_warning: Option<String>,
    /// Result of [`crate::guard::check_self_preload`] — `Ok(())` if clean,
    /// `Err(message)` if `LD_PRELOAD` / `LD_AUDIT` is set in our process env.
    pub self_preload: Result<(), String>,
    /// Full GUI security assessment.
    pub gui: crate::guard::GuiSecurityReport,
    /// Hostile/Degraded/Safe environment classification.
    pub environment: EnvThreatLabel,
    /// Vault root path and whether it exists / has been initialized.
    pub vault: VaultProbe,
    /// `OsCapabilities::detect()` snapshot.
    pub capabilities: crate::sandbox::OsCapabilities,
    /// Active hardware keystore backend (DPAPI / Secure Enclave / TPM 2.0
    /// / None) and its protection tier (1 = true hardware seal,
    /// 2 = OS-bound, 3 = passphrase-only).
    pub keystore: KeystoreProbe,
    /// Every detector signal that fired, in stable taxonomic form.
    /// One entry per signal — id, category, severity, label,
    /// detail, mitigation. Renderers can surface this for
    /// transparency and policy-override authoring; CI can
    /// machine-parse it to gate releases.
    pub signals: Vec<SignalProbe>,
}

/// Single signal as surfaced by [`doctor_report`]. Stripped down to
/// owned strings so the report can be serialized and round-tripped
/// without lifetime headaches.
#[derive(Debug, Clone)]
pub struct SignalProbe {
    /// Stable signal identifier (e.g. `gui.input_injector`).
    pub id: String,
    /// Category string (`input_injection`, `gui_presence`, …).
    pub category: String,
    /// Severity string (`info` / `warn` / `degraded` / `hostile` / `critical`).
    pub severity: String,
    /// Short human-readable label.
    pub label: String,
    /// Per-detection contextual detail.
    pub detail: String,
    /// One-line mitigation hint.
    pub mitigation: String,
}

/// Hardware-keystore status for `envseal doctor`.
#[derive(Debug, Clone)]
pub struct KeystoreProbe {
    /// Backend identifier as returned by [`crate::keychain::active_backend`].
    pub backend: crate::vault::hardware::Backend,
    /// Protection tier: 1 = hardware seal, 2 = OS-bound, 3 = none.
    pub tier: u8,
    /// Human-readable backend name for display.
    pub name: &'static str,
}

/// Tagged version of [`crate::guard::EnvironmentThreatLevel`] suitable for
/// caller pattern-matching without re-importing the guard enum.
#[derive(Debug, Clone)]
pub enum EnvThreatLabel {
    /// No hostile or degraded vars present.
    Safe,
    /// Suspicious vars (e.g. `LD_LIBRARY_PATH`) — not blocking by default.
    Degraded(String),
    /// Active injection vars (`LD_PRELOAD` etc.) — blocking by tier policy.
    Hostile(String),
}

/// Vault-root probe surfaced by [`doctor_report`].
#[derive(Debug, Clone)]
pub struct VaultProbe {
    /// Root directory (`~/.config/envseal` etc.).
    pub root: PathBuf,
    /// Whether the directory exists.
    pub exists: bool,
    /// Whether the master key file exists inside it.
    pub master_key_present: bool,
}

/// Run every doctor probe and assemble the report.
///
/// Cheap and side-effect-free at the OS level: probes `/proc`, env vars, and
/// the vault directory; never opens the vault.
///
/// # Errors
/// Returns `Error::StorageIo` if `vault_root` resolution fails.
pub fn doctor_report() -> Result<DoctorReport, Error> {
    let root = vault_root()?;
    let exists = root.exists();
    let master_key_present = exists && root.join("master.key").exists();

    let environment = match crate::guard::assess_environment() {
        crate::guard::EnvironmentThreatLevel::Safe => EnvThreatLabel::Safe,
        crate::guard::EnvironmentThreatLevel::Degraded(s) => EnvThreatLabel::Degraded(s),
        crate::guard::EnvironmentThreatLevel::Hostile(s) => EnvThreatLabel::Hostile(s),
    };

    let backend = crate::keychain::active_backend();
    let keystore = KeystoreProbe {
        backend,
        tier: backend.tier(),
        name: backend.name(),
    };

    // doctor is an ambient host scan — it has no target binary or
    // operation context, so per-operation detectors stay quiet.
    let doctor_ctx = crate::guard::DetectorContext::ambient();
    let signals = crate::guard::assess_all_signals(&doctor_ctx)
        .into_iter()
        .map(|s| SignalProbe {
            id: s.id.as_str().to_string(),
            category: s.category.as_str().to_string(),
            severity: s.severity.as_str().to_string(),
            label: s.label.to_string(),
            detail: s.detail,
            mitigation: s.mitigation.to_string(),
        })
        .collect();

    Ok(DoctorReport {
        ptrace_scope_warning: crate::guard::check_ptrace_scope(),
        self_preload: crate::guard::check_self_preload().map_err(|e| e.to_string()),
        gui: crate::guard::assess_gui_security(),
        environment,
        vault: VaultProbe {
            root,
            exists,
            master_key_present,
        },
        capabilities: crate::sandbox::OsCapabilities::detect(),
        keystore,
        signals,
    })
}

/// Verify a GUI dialog binary by name (`zenity`, `kdialog`).
///
/// Re-exported so CLI command files don't need to import the `guard` module.
///
/// # Errors
/// Returns whatever [`crate::guard::verify_gui_binary`] returns.
pub fn verify_gui_binary(name: &str) -> Result<PathBuf, Error> {
    crate::guard::verify_gui_binary(name)
}

// Import / shred / rotate moved to `ops::lifecycle`.

/// Validate that `path` (from untrusted MCP-style input) does not escape the process cwd.
///
/// Returns the resolved absolute path. Canonicalize failures use generic messages so
/// host `io::Error` strings are not surfaced to remote tool clients.
pub fn validate_path_within_cwd(
    path: &str,
    field: &str,
    max_path_len: usize,
) -> Result<PathBuf, String> {
    if path.contains('\0') {
        return Err(format!("{field} contains invalid characters"));
    }
    if path.len() > max_path_len {
        return Err(format!(
            "{field} exceeds maximum length of {max_path_len} bytes"
        ));
    }
    let p = Path::new(path);
    if p.components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return Err(format!("{field} must not escape the working directory"));
    }
    let cwd =
        std::env::current_dir().map_err(|_| "cannot determine working directory".to_string())?;
    let cwd = std::fs::canonicalize(&cwd).map_err(|_| {
        "cannot resolve working directory (symlink or permission error)".to_string()
    })?;
    let abs = if p.is_absolute() {
        p.to_path_buf()
    } else {
        cwd.join(p)
    };
    let resolved = if abs.exists() {
        std::fs::canonicalize(&abs)
            .map_err(|_| format!("failed to resolve {field} (path or permission error)"))?
    } else {
        // Walk up to the deepest existing ancestor, then re-append the missing suffix.
        // This is more robust than `canonicalize(parent)` because the *parent* may
        // not exist either (e.g. `/etc/passwd` on Windows where `/etc` is also absent).
        let mut anchor = abs.clone();
        let mut suffix: Vec<std::ffi::OsString> = Vec::new();
        while !anchor.exists() {
            let leaf = anchor
                .file_name()
                .ok_or_else(|| format!("{field} has no resolvable ancestor"))?
                .to_os_string();
            suffix.push(leaf);
            match anchor.parent() {
                Some(parent) => anchor = parent.to_path_buf(),
                None => return Err(format!("{field} has no resolvable ancestor")),
            }
        }
        let canonical_anchor = std::fs::canonicalize(&anchor)
            .map_err(|_| format!("failed to resolve {field} (path or permission error)"))?;
        let mut resolved = canonical_anchor;
        for piece in suffix.into_iter().rev() {
            resolved.push(piece);
        }
        resolved
    };
    if resolved != cwd && !resolved.starts_with(cwd.join("")) {
        return Err(format!("{field} must not escape the working directory"));
    }
    Ok(resolved)
}

/// Request a secret through GUI and store it in the default vault.
///
/// Returns any entropy warnings from the stored value so callers can
/// surface them to the user. The secret itself is never returned.
pub fn request_key(name: &str, description: &str) -> Result<Vec<crate::guard::Signal>, Error> {
    let vault = Vault::open_default()?;
    let config = security_config::load_config(vault.root(), vault.master_key_bytes())?;
    // SECURITY: prepend a source-authentication banner so the user
    // can distinguish AI-agent-initiated prompts from human-initiated
    // ones. Prevents social-engineering attacks where a compromised
    // agent requests a sensitive key with a misleading description.
    let agent_desc = format!(
        "[AGENT-INITIATED REQUEST]\n\n{description}\n\n---\nThis prompt was initiated by an automated agent. Verify the secret name ('{name}') before entering any credentials."
    );
    let secret = gui::request_secret_value(name, &agent_desc, &config)?;
    if secret.is_empty() {
        return Err(Error::CryptoFailure(
            "refusing to store an empty secret".to_string(),
        ));
    }
    store_secret(name, secret.as_bytes(), false)
}

// CTF operations moved to `ops::ctf`.
// Public API preserved by the `pub use ctf::*;` re-export above.

// Developer-migration ops moved to `ops::dev_migration`.
// Public API preserved by the `pub use dev_migration::*;` re-export above.