envseal 0.3.12

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 `""`.
///
/// # Audit M15 — hashed, not raw
///
/// Earlier versions stored the raw `argv[1..]` string in `policy.toml`.
/// If a user accidentally passed a secret on the command line — say
/// `--token=sk-abc123` — that secret would be persisted in plaintext
/// inside `policy.toml` (which is encrypted-at-rest now via
/// `sealed_blob`, but used to be plaintext-with-HMAC and could still
/// re-leak through copy/paste, debug logs, or a misconfigured
/// permission). The fingerprint is now an HKDF-SHA256 hex digest over
/// the canonical argv encoding — every argv shape still produces a
/// unique fingerprint, but the original argv contents cannot be
/// recovered from the stored hash.
///
/// The separator inside the canonical encoding 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. The escape (`\x1f` ->
/// `\x1f\x1f`) preserves the same collision-free property after
/// hashing.
#[must_use]
pub fn command_fingerprint(command: &[String]) -> String {
    if command.len() <= 1 {
        return String::new();
    }
    let canonical: String = command[1..]
        .iter()
        .map(|s| s.replace('\x1f', "\x1f\x1f"))
        .collect::<Vec<_>>()
        .join("\x1f");
    hash_canonical_argv(&canonical)
}

/// Apply HKDF-SHA256 with a domain-separated salt to the canonical
/// argv string and return the lowercase-hex digest. Domain salt
/// `b"envseal-argv-fingerprint-v1"` ensures the hash space is distinct
/// from any other HKDF use elsewhere in the codebase.
#[allow(clippy::expect_used)] // HKDF expand of 32 bytes is infallible per RFC 5869 §2.3
fn hash_canonical_argv(canonical: &str) -> String {
    use hkdf::Hkdf;
    use sha2::Sha256;
    use std::fmt::Write;

    const DOMAIN: &[u8] = b"envseal-argv-fingerprint-v1";
    // HKDF wants an IKM and (optionally) a salt; we use the canonical
    // string as IKM and the domain string as salt. The output info
    // is also domain-tagged so a future bump produces a fresh hash
    // space (rules with the old hash would naturally re-prompt — the
    // approval gate is what we want when format breaks).
    let hk = Hkdf::<Sha256>::new(Some(DOMAIN), canonical.as_bytes());
    let mut out = [0u8; 32];
    hk.expand(b"argv-fingerprint", &mut out)
        .expect("HKDF expand of 32 bytes is mathematically infallible per RFC 5869");
    let mut hex = String::with_capacity(out.len() * 2);
    for b in &out {
        let _ = write!(hex, "{b:02x}");
    }
    hex
}

/// 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> {
    // GUARD: Refuse to proceed when the *current* envseal process
    // has an LD_PRELOAD / DYLD_INSERT_LIBRARIES / library-injection
    // pattern set — anything that lets foreign code into envseal's
    // address space can read the decrypted secret out of memory
    // *before* it reaches the child. `pipe.rs` already calls this;
    // `inject` and `supervised` were missing it (audit C3).
    crate::guard::check_self_preload()?;
    crate::guard::harden_process();

    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}"
        ))))
    }
}

/// Like [`execute_multi_with_options`] but returns the child
/// process's actual exit code (or `-1` for unknown / killed by
/// signal). Audit M10: the MCP `inject` / `run` / `pipe` tools used
/// to hardcode `exit_code: 0` in their JSON-RPC responses, hiding
/// non-zero exits from the calling agent (which often *needed* to
/// distinguish "ran fine" from "ran but found something" — e.g. a
/// grep-style child). Errors that occurred *before* the spawn (vault
/// locked, policy rejected, binary not found) still come back as
/// `Err(Error::*)` — the returned `i32` is only the child's exit
/// code, never an envseal-side failure surrogate.
pub fn execute_multi_capture_status(
    vault: &Vault,
    mappings: &[(&str, &str)],
    command: &[String],
    options: InjectExecOptions,
) -> Result<i32, Error> {
    crate::guard::check_self_preload()?;
    crate::guard::harden_process();
    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)?;
    Ok(status.code().unwrap_or(-1))
}