envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Per-call context for signal detectors.
//!
//! Some detectors are purely *ambient* — they inspect the host
//! (running processes, env vars, GUI state) and don't care about what
//! operation is in flight. Others need to know what the operation is
//! about: the target binary path for an [`crate::gui::request_approval`]
//! call, the vault root for a disk-permission check, the stdin kind
//! for an i/o-context warning.
//!
//! Rather than splitting detectors into "ambient" and "per-op" registries
//! and forcing the approval pipeline to run two iteration loops, every
//! detector receives one [`DetectorContext`]. Ambient detectors ignore
//! the fields they don't need. New context fields can be added without
//! breaking detectors that don't read them.
//!
//! The context is constructed by the caller — see [`DetectorContext::builder`] — and
//! flows unchanged through [`super::assess_all_signals`] into each
//! registered detector.

use std::path::Path;

/// What kind of file descriptor the calling process has on stdin.
///
/// `!std::io::stdin().is_terminal()` is too loose: a closed stdin, a
/// `/dev/null` redirect, and an actual pipe all return the same
/// "non-tty" answer. The piped-stdin warning is only meaningful when
/// stdin is a real pipe — if it's null or a regular file the bytes
/// can't be attacker-injected after launch.
///
/// `Unknown` means the platform-specific detection couldn't classify
/// the handle (rare; treat as if it could be anything).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StdinKind {
    /// Connected to an interactive terminal (typing).
    Tty,
    /// Connected to a real pipe / FIFO (`echo x | …`, `cmd | envseal …`).
    /// This is the case the piped-input warning was originally meant
    /// to cover.
    Pipe,
    /// Connected to a regular file (`< file.txt`).
    File,
    /// Closed or `/dev/null` — no bytes to read.
    Null,
    /// Couldn't classify — platform-specific probe failed, OR the
    /// caller never probed (ambient context). This is the safe
    /// default for `DetectorContext::ambient()`: detectors that fire
    /// only on a positive `Pipe` match stay quiet, since "we don't
    /// know" is not the same as "definitely a pipe."
    #[default]
    Unknown,
}

impl StdinKind {
    /// Stable string used in audit logs.
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Tty => "tty",
            Self::Pipe => "pipe",
            Self::File => "file",
            Self::Null => "null",
            Self::Unknown => "unknown",
        }
    }
}

/// Detect the kind of fd attached to this process's stdin.
///
/// Uses platform-specific introspection so a closed/null/file stdin
/// is classified separately from a real pipe. This is the entire
/// reason `StdinKind` exists — callers used to call
/// `!std::io::stdin().is_terminal()` and treat every non-tty as a
/// piped attacker payload, which is the false-alarm we're undoing.
#[must_use]
pub fn detect_stdin_kind() -> StdinKind {
    #[cfg(windows)]
    {
        windows_stdin_kind()
    }
    #[cfg(unix)]
    {
        unix_stdin_kind()
    }
    #[cfg(not(any(windows, unix)))]
    {
        StdinKind::Unknown
    }
}

#[cfg(windows)]
fn windows_stdin_kind() -> StdinKind {
    // GetFileType on the stdin handle returns FILE_TYPE_CHAR for
    // consoles, FILE_TYPE_PIPE for pipes (and sockets), FILE_TYPE_DISK
    // for files, and FILE_TYPE_UNKNOWN/0 for closed/null handles.
    // STD_INPUT_HANDLE = -10 cast to u32. We avoid pulling in winapi
    // for one constant: the values are stable Windows ABI.
    use std::os::windows::io::AsRawHandle;
    const FILE_TYPE_DISK: u32 = 0x0001;
    const FILE_TYPE_CHAR: u32 = 0x0002;
    const FILE_TYPE_PIPE: u32 = 0x0003;
    const FILE_TYPE_UNKNOWN: u32 = 0x0000;

    extern "system" {
        fn GetFileType(handle: *mut std::ffi::c_void) -> u32;
        fn GetLastError() -> u32;
    }

    let handle = std::io::stdin().as_raw_handle();
    if handle.is_null() {
        return StdinKind::Null;
    }
    // SAFETY: `handle` is the raw stdin handle owned by the process.
    // GetFileType only reads the handle's type and never frees it.
    let kind = unsafe { GetFileType(handle.cast::<std::ffi::c_void>()) };
    if kind == FILE_TYPE_UNKNOWN {
        // SAFETY: Win32 GetLastError is always callable from any thread.
        let err = unsafe { GetLastError() };
        // err == 0 (NO_ERROR) and FILE_TYPE_UNKNOWN means a console
        // detached handle or NUL — treat as Null. err != 0 means the
        // probe failed; classify Unknown so callers don't false-alarm.
        return if err == 0 {
            StdinKind::Null
        } else {
            StdinKind::Unknown
        };
    }
    match kind {
        FILE_TYPE_CHAR => {
            // Could be a real console OR the NUL device. is_terminal
            // distinguishes — a real console is interactive.
            use std::io::IsTerminal;
            if std::io::stdin().is_terminal() {
                StdinKind::Tty
            } else {
                StdinKind::Null
            }
        }
        FILE_TYPE_PIPE => StdinKind::Pipe,
        FILE_TYPE_DISK => StdinKind::File,
        _ => StdinKind::Unknown,
    }
}

#[cfg(unix)]
fn unix_stdin_kind() -> StdinKind {
    use std::io::IsTerminal;
    use std::os::unix::io::AsRawFd;

    let fd = std::io::stdin().as_raw_fd();
    if fd < 0 {
        return StdinKind::Null;
    }

    // SAFETY: fstat on a valid fd just fills in the buffer. We read
    // mode bits afterwards — no aliasing concerns.
    let mut statbuf: libc::stat = unsafe { std::mem::zeroed() };
    let rc = unsafe { libc::fstat(fd, &mut statbuf) };
    if rc != 0 {
        return StdinKind::Unknown;
    }

    let mode = statbuf.st_mode;
    if (mode & libc::S_IFMT) == libc::S_IFIFO {
        return StdinKind::Pipe;
    }
    if (mode & libc::S_IFMT) == libc::S_IFSOCK {
        // Socket-redirected stdin is the same threat model as a pipe:
        // attacker-controllable bytes after launch.
        return StdinKind::Pipe;
    }
    if (mode & libc::S_IFMT) == libc::S_IFCHR {
        return if std::io::stdin().is_terminal() {
            StdinKind::Tty
        } else {
            // /dev/null is a char device but not a terminal.
            StdinKind::Null
        };
    }
    if (mode & libc::S_IFMT) == libc::S_IFREG {
        return StdinKind::File;
    }
    StdinKind::Unknown
}

/// Per-call context handed to every signal detector.
///
/// Construct via [`DetectorContext::ambient`] for ambient host-state
/// scans (doctor, startup audit) or [`DetectorContext::builder`] when
/// you have operation-specific context (binary path, vault root,
/// stdin kind).
#[derive(Debug, Clone, Default)]
pub struct DetectorContext<'a> {
    /// The target binary an approval popup is about to authorize, if
    /// the call site is an inject/run/shell flow. `None` for
    /// ambient host scans (`doctor`, `startup_audit`).
    pub binary_path: Option<&'a str>,
    /// Stdin handle classification — `Pipe` is the case the piped-
    /// input warning was originally meant for. Defaults to
    /// `Unknown` so detectors that *only* fire on a positive `Pipe`
    /// match are quiet by default.
    pub stdin_kind: StdinKind,
    /// Vault root for disk-permission detectors. `None` when the
    /// caller doesn't have a vault-rooted operation in flight.
    pub vault_root: Option<&'a Path>,
}

impl<'a> DetectorContext<'a> {
    /// Context with no per-operation fields populated. Detectors that
    /// look at `binary_path` / `stdin_kind` / `vault_root` see the
    /// defaults (`None` / `Unknown` / `None`) and emit nothing —
    /// which is what an ambient host scan wants.
    #[must_use]
    pub fn ambient() -> Self {
        Self::default()
    }

    /// Builder for operation-specific contexts.
    #[must_use]
    pub fn builder() -> DetectorContextBuilder<'a> {
        DetectorContextBuilder::default()
    }
}

/// Fluent builder for [`DetectorContext`]. Set only the fields the
/// call site has — any field left unset gets the ambient default.
#[derive(Debug, Default)]
pub struct DetectorContextBuilder<'a> {
    inner: DetectorContext<'a>,
}

impl<'a> DetectorContextBuilder<'a> {
    /// Populate the target binary path. Used by approval flows so
    /// `assess_target_binary_signals` can fire.
    #[must_use]
    pub fn binary_path(mut self, path: &'a str) -> Self {
        self.inner.binary_path = Some(path);
        self
    }

    /// Populate the stdin classification. Pass the result of
    /// [`detect_stdin_kind`] from the caller's process — never
    /// detect inside a detector, so unit tests can drive the value.
    #[must_use]
    pub fn stdin_kind(mut self, kind: StdinKind) -> Self {
        self.inner.stdin_kind = kind;
        self
    }

    /// Populate the vault root for disk-permission detectors.
    #[must_use]
    pub fn vault_root(mut self, root: &'a Path) -> Self {
        self.inner.vault_root = Some(root);
        self
    }

    /// Finalize.
    #[must_use]
    pub fn build(self) -> DetectorContext<'a> {
        self.inner
    }
}

/// Detector entry registered in [`super::DETECTORS`]. Emits a
/// `Hostile`-severity signal when the calling process's stdin is a
/// real pipe — that's the case where attacker-controllable bytes
/// could be flowing into the child via stdin pass-through, which is
/// the original threat the warning was meant to surface.
///
/// Crucially, [`StdinKind::Null`], [`StdinKind::File`], [`StdinKind::Tty`],
/// and [`StdinKind::Unknown`] all stay quiet — those are the cases
/// the old `!is_terminal()` heuristic flagged as "piped" and produced
/// false alarms in CI, agents, and any non-interactive launcher.
#[must_use]
pub fn assess_io_context_signals(ctx: &super::DetectorContext) -> Vec<super::Signal> {
    use super::{Category, Severity, Signal, SignalId};
    if ctx.stdin_kind != StdinKind::Pipe {
        return Vec::new();
    }
    vec![Signal::new(
        SignalId::new("io.stdin.piped"),
        Category::IoContext,
        Severity::Hostile,
        "stdin is a pipe",
        "the calling process has a real pipe on stdin — bytes on that pipe are \
         attacker-controllable after launch and will be passed through to the \
         child if the target binary reads stdin",
        "if you didn't intend to pipe input into envseal, run the same command without `… | envseal`",
    )]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ambient_context_has_no_per_op_fields() {
        let ctx = DetectorContext::ambient();
        assert!(ctx.binary_path.is_none());
        assert_eq!(ctx.stdin_kind, StdinKind::Unknown);
        assert!(ctx.vault_root.is_none());
    }

    #[test]
    fn builder_sets_only_fields_the_caller_specified() {
        let ctx = DetectorContext::builder()
            .binary_path("/usr/bin/python3")
            .stdin_kind(StdinKind::Pipe)
            .build();
        assert_eq!(ctx.binary_path, Some("/usr/bin/python3"));
        assert_eq!(ctx.stdin_kind, StdinKind::Pipe);
        assert!(ctx.vault_root.is_none());
    }

    #[test]
    fn stdin_kind_strings_are_stable() {
        assert_eq!(StdinKind::Tty.as_str(), "tty");
        assert_eq!(StdinKind::Pipe.as_str(), "pipe");
        assert_eq!(StdinKind::File.as_str(), "file");
        assert_eq!(StdinKind::Null.as_str(), "null");
        assert_eq!(StdinKind::Unknown.as_str(), "unknown");
    }

    #[test]
    fn io_context_detector_quiet_for_non_pipe_stdin() {
        // Catches the v0.2.0 false-alarm regression: previously the
        // approval popup yelled CRITICAL whenever stdin wasn't a
        // TTY, which was every CI run, every IDE shell, every agent
        // harness. Each non-Pipe variant must produce zero signals.
        for kind in [
            StdinKind::Tty,
            StdinKind::File,
            StdinKind::Null,
            StdinKind::Unknown,
        ] {
            let ctx = super::DetectorContext::builder().stdin_kind(kind).build();
            let signals = super::assess_io_context_signals(&ctx);
            assert!(
                signals.is_empty(),
                "stdin={kind:?} produced {} unexpected signal(s)",
                signals.len()
            );
        }
    }

    #[test]
    fn io_context_detector_fires_on_real_pipe() {
        let ctx = super::DetectorContext::builder()
            .stdin_kind(StdinKind::Pipe)
            .build();
        let signals = super::assess_io_context_signals(&ctx);
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].id.as_str(), "io.stdin.piped");
        assert_eq!(signals[0].severity, super::super::Severity::Hostile);
    }
}