envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Direct exec-replace injection — `Command::status()` with secrets in env.
//!
//! This is the simplest execution mode: the prepared child takes over the
//! current process slot (or runs as a sibling and is `wait`'d on) with the
//! decrypted secret(s) injected via environment variables. No supervision,
//! no leak detection — when raw speed and minimal overhead matter.
//!
//! Also home of two security primitives that aren't tied to a single
//! execution mode but logically belong with the inject surface:
//!
//! - [`validate_env_var_name`] — rejects env-var names that would weaponize
//!   the loader (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, …) or violate the
//!   POSIX `IEEE Std 1003.1` name grammar.
//! - [`command_fingerprint`] — argv-binding fingerprint used to scope an
//!   approval to a specific invocation pattern (so an approval for
//!   `wrangler deploy` doesn't auto-allow `wrangler --shell evil.sh`).

#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};

use crate::error::Error;
use crate::vault::Vault;

use super::context::prepare_execution;

/// Validate an env-var name about to receive a decrypted secret.
///
/// Rejects:
/// - Names on the loader-injection blocklist (`LD_PRELOAD`, `LD_AUDIT`,
///   `DYLD_INSERT_LIBRARIES`) — assigning these to a secret value would
///   make the secret bytes a library path that the dynamic linker loads
///   into every child of this process.
/// - Names on the suspicious-loader blocklist (`LD_LIBRARY_PATH`,
///   `PYTHONPATH`, `NODE_OPTIONS`, `BASH_ENV`, …) for the same reason at a
///   weaker tier (resolver redirection rather than direct injection).
/// - Names that don't match POSIX `IEEE Std 1003.1` env-var grammar:
///   first character must be ASCII letter or underscore; subsequent
///   characters must be ASCII alphanumeric or underscore.
///
/// The blocklist mirrors the parent-process scrubbing performed by
/// [`crate::guard::env::sanitized_env`] — we refuse to *create* a child
/// env-var that we'd refuse to *inherit*.
///
/// # Errors
///
/// Returns [`Error::BinaryResolution`] with a message containing
/// `"injection blocklist"` or `"invalid env var name"`.
pub fn validate_env_var_name(name: &str) -> Result<(), Error> {
    use crate::guard::env::{INJECT_ENV_VARS, SUSPICIOUS_ENV_VARS};

    if INJECT_ENV_VARS.contains(&name) || SUSPICIOUS_ENV_VARS.contains(&name) {
        return Err(Error::BinaryResolution(format!(
            "env var '{name}' is on the injection blocklist; using it as a target would \
             let the secret value redirect the loader."
        )));
    }

    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return Err(Error::BinaryResolution(
            "invalid env var name: empty string".to_string(),
        ));
    };
    if !(first.is_ascii_alphabetic() || first == '_') {
        return Err(Error::BinaryResolution(format!(
            "invalid env var name '{name}': must start with an ASCII letter or underscore"
        )));
    }
    for c in chars {
        if !(c.is_ascii_alphanumeric() || c == '_') {
            return Err(Error::BinaryResolution(format!(
                "invalid env var name '{name}': only ASCII alphanumerics and underscore allowed"
            )));
        }
    }
    Ok(())
}

/// Argv-binding fingerprint for command-pattern-scoped approval.
///
/// Returns an opaque string that uniquely identifies the argv shape of a
/// command (excluding `argv[0]`). Two different argv arrays produce two
/// different fingerprints. The empty argv and a single-element argv (binary
/// only, no args) both produce `""`.
///
/// The separator is U+001F (`UNIT SEPARATOR`), chosen because it cannot
/// occur inside a normal argv element. A space-based separator would let
/// `["python", "app.py extra"]` collide with `["python", "app.py", "extra"]`
/// — letting an attacker reuse one approval for the other.
#[must_use]
pub fn command_fingerprint(command: &[String]) -> String {
    if command.len() <= 1 {
        return String::new();
    }
    // Escape the separator inside arguments so that
    // ["bin", "x", "y\x1fz"] and ["bin", "x\x1fy", "z"] do not
    // produce identical fingerprints.
    command[1..]
        .iter()
        .map(|s| s.replace('\x1f', "\x1f\x1f"))
        .collect::<Vec<_>>()
        .join("\x1f")
}

/// A request to inject a single secret into a child process.
pub struct InjectRequest<'a> {
    /// Name of the secret in the vault.
    pub secret_name: &'a str,
    /// Environment variable name to inject as.
    pub env_var: &'a str,
    /// The command to execute (first element is the binary).
    pub command: &'a [String],
}

/// Execution options for process launch behavior.
#[derive(Debug, Clone, Copy, Default)]
pub struct InjectExecOptions {
    /// When true, child stdio is detached from parent stdio.
    /// Useful for MCP stdio transports where child output must not
    /// corrupt JSON-RPC frames.
    pub isolate_stdio: bool,
}

/// Execute an inject request: decrypt the secret, enforce policy, and
/// exec the child process with the secret as an env var.
///
/// # Errors
///
/// Returns an error if the binary can't be resolved, the vault is locked,
/// the user denies approval, or the child process fails.
pub fn execute(vault: &Vault, request: &InjectRequest) -> Result<(), Error> {
    execute_with_options(vault, request, InjectExecOptions::default())
}

/// Execute an inject request with explicit execution options.
///
/// # Errors
/// See [`execute`].
pub fn execute_with_options(
    vault: &Vault,
    request: &InjectRequest,
    options: InjectExecOptions,
) -> Result<(), Error> {
    execute_multi_with_options(
        vault,
        &[(request.secret_name, request.env_var)],
        request.command,
        options,
    )
}

/// Inject multiple secrets into a single child process.
///
/// Each mapping is a `(secret_name, env_var_name)` pair. All secrets must
/// be individually authorized.
///
/// # Errors
/// See [`execute`].
pub fn execute_multi(
    vault: &Vault,
    mappings: &[(&str, &str)],
    command: &[String],
) -> Result<(), Error> {
    execute_multi_with_options(vault, mappings, command, InjectExecOptions::default())
}

/// Inject multiple secrets with explicit execution options.
///
/// # Errors
/// See [`execute`].
pub fn execute_multi_with_options(
    vault: &Vault,
    mappings: &[(&str, &str)],
    command: &[String],
    options: InjectExecOptions,
) -> Result<(), Error> {
    let prepared = prepare_execution(vault, mappings, command)?;

    let mut cmd = Command::new(&prepared.exec_path);
    #[cfg(unix)]
    cmd.arg0(&prepared.binary_path);
    cmd.args(&prepared.args);
    cmd.env_clear();
    for (k, v) in &prepared.clean_env {
        cmd.env(k, v);
    }
    for (var, val) in &prepared.env_pairs {
        cmd.env(var, val.as_str());
    }

    if options.isolate_stdio {
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
    }

    let status = cmd.status().map_err(Error::ExecFailed)?;
    if status.success() {
        Ok(())
    } else {
        Err(Error::ExecFailed(std::io::Error::other(format!(
            "child process exited with status {status}"
        ))))
    }
}