pub mod linux;
pub mod macos;
pub mod relay;
pub mod windows;
#[cfg(feature = "mock-gui")]
pub mod mock;
use crate::audit;
use crate::error::Error;
use crate::guard;
use crate::security_config::SecurityConfig;
use std::process::Command;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
#[cfg(target_os = "linux")]
use linux::{
linux_fido2_pin_entry, linux_passphrase, linux_popup, linux_preexec_prompt, linux_secret_value,
linux_totp_entry, resolve_linux_dialog, DialogKind,
};
#[cfg(target_os = "macos")]
use macos::{
macos_fido2_pin_entry, macos_passphrase, macos_popup, macos_preexec_prompt, macos_secret_value,
macos_totp_entry,
};
#[cfg(target_os = "windows")]
use windows::{
windows_fido2_pin_entry, windows_passphrase, windows_popup, windows_preexec_prompt,
windows_secret_value, windows_totp_entry,
};
#[cfg(feature = "mock-gui")]
fn assert_mock_gui_safe() {
if cfg!(test) {
return;
}
for marker in ["CARGO_TARGET_TMPDIR", "CARGO_MANIFEST_DIR"] {
if std::env::var_os(marker).is_some() {
return;
}
}
eprintln!(
"envseal: SECURITY ABORT — `mock-gui` feature reached from outside \
a recognized Cargo test harness.\n\
A downstream dependency may have enabled this feature, which \
bypasses the human-in-the-loop approval boundary."
);
std::process::abort();
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Approval {
AllowOnce,
AllowAlways,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreexecChoice {
Store,
Skip,
DontAskAgain,
}
#[cfg_attr(feature = "mock-gui", allow(unreachable_code, unused_variables))]
pub fn preexec_capture_prompt(message: &str) -> Result<PreexecChoice, Error> {
#[cfg(feature = "mock-gui")]
{
assert_mock_gui_safe();
return Ok(mock::get_mock_preexec().unwrap_or(PreexecChoice::Skip));
}
if !has_display() {
return Ok(PreexecChoice::Skip);
}
#[cfg(target_os = "linux")]
{
linux_preexec_prompt(message)
}
#[cfg(target_os = "macos")]
{
macos_preexec_prompt(message)
}
#[cfg(target_os = "windows")]
{
windows_preexec_prompt(message)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = message;
Ok(PreexecChoice::Skip)
}
}
static RATE_LIMITER: Mutex<RateLimiterState> = Mutex::new(RateLimiterState {
recent_requests: Vec::new(),
last_request: None,
});
struct RateLimiterState {
recent_requests: Vec<Instant>,
last_request: Option<Instant>,
}
#[cfg_attr(feature = "mock-gui", allow(unreachable_code, unused_variables))]
#[allow(clippy::too_many_lines)]
pub fn request_approval(
binary_path: &str,
command: &[String],
secret_name: &str,
env_var: &str,
config: &SecurityConfig,
) -> Result<Approval, Error> {
#[cfg(feature = "mock-gui")]
{
assert_mock_gui_safe();
if let Some(response) = mock::get_mock_approval() {
return response;
}
}
let cmd_str = command.join(" ");
enforce_rate_limit(config)?;
if config.relay_required {
match relay::request_relay_approval(config, binary_path, secret_name, env_var) {
Ok(relay::RelayDecision::Allow) => return Ok(Approval::AllowOnce),
Ok(relay::RelayDecision::Deny | relay::RelayDecision::Timeout) => {
return Err(Error::UserDenied);
}
Err(e) => {
return Err(Error::RelayRequiredButUnavailable(e.to_string()));
}
}
}
#[cfg(feature = "mock-gui")]
{
return Err(Error::NoDisplay);
}
if !has_display() {
return Err(Error::NoDisplay);
}
let ctx = guard::DetectorContext::builder()
.binary_path(binary_path)
.stdin_kind(guard::detect_stdin_kind())
.build();
let signals = guard::assess_all_signals(&ctx);
let policy = config.build_policy();
let decision = guard::evaluate(&signals, &policy, config.tier);
for sig in &decision.log_entries {
audit::log(&audit::AuditEvent::SignalRecorded {
tier: format!("{:?}", config.tier),
classification: format!("{} [{}] {}", sig.severity.as_str(), sig.id, sig.label),
})
.map_err(|e| Error::AuditLogFailed(e.to_string()))?;
}
if let Some(blocking) = decision.blocking_signal.as_ref() {
return Err(Error::EnvironmentCompromised(format!(
"{label} ({id}): {detail} — {mitigation}",
label = blocking.label,
id = blocking.id,
detail = blocking.detail,
mitigation = blocking.mitigation,
)));
}
if (config.challenge_required || decision.needs_friction)
&& !challenge_already_passed_in_process()
{
challenge_gate()?;
mark_challenge_passed_in_process();
}
if config.approval_delay_secs > 0 {
std::thread::sleep(Duration::from_secs(config.approval_delay_secs.into()));
}
let mut warnings = String::new();
for warning in &decision.warnings {
warnings.push_str(warning);
warnings.push('\n');
}
let safe_binary = sanitize_description(binary_path);
let safe_cmd = sanitize_description(&cmd_str);
let safe_secret = sanitize_description(secret_name);
let safe_env = sanitize_description(env_var);
let safe_warnings = sanitize_description(&warnings);
#[cfg(target_os = "linux")]
{
linux_popup(
&safe_binary,
&safe_cmd,
&safe_secret,
&safe_env,
&safe_warnings,
)
}
#[cfg(target_os = "macos")]
{
macos_popup(
&safe_binary,
&safe_cmd,
&safe_secret,
&safe_env,
&safe_warnings,
)
}
#[cfg(target_os = "windows")]
{
windows_popup(
&safe_binary,
&safe_cmd,
&safe_secret,
&safe_env,
&safe_warnings,
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
pub fn request_passphrase(
is_new: bool,
config: &SecurityConfig,
) -> Result<Zeroizing<String>, Error> {
request_passphrase_with_hint(is_new, None, config)
}
#[cfg_attr(feature = "mock-gui", allow(unreachable_code, unused_variables))]
pub fn request_passphrase_with_hint(
is_new: bool,
prev_error: Option<&str>,
_config: &SecurityConfig,
) -> Result<Zeroizing<String>, Error> {
#[cfg(feature = "mock-gui")]
{
assert_mock_gui_safe();
return mock::get_mock_passphrase().ok_or(Error::NoDisplay);
}
if !has_display() {
return Err(Error::NoDisplay);
}
#[cfg(target_os = "linux")]
{
linux_passphrase(is_new, prev_error)
}
#[cfg(target_os = "macos")]
{
macos_passphrase(is_new, prev_error)
}
#[cfg(target_os = "windows")]
{
windows_passphrase(is_new, prev_error)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = prev_error;
Err(Error::NoDisplay)
}
}
#[cfg_attr(feature = "mock-gui", allow(unreachable_code, unused_variables))]
pub fn request_secret_value(
key_name: &str,
description: &str,
_config: &SecurityConfig,
) -> Result<Zeroizing<String>, Error> {
#[cfg(feature = "mock-gui")]
{
assert_mock_gui_safe();
return mock::get_mock_secret_value().ok_or(Error::NoDisplay);
}
if !has_display() {
return Err(Error::NoDisplay);
}
let safe_desc = sanitize_description(description);
#[cfg(target_os = "linux")]
{
linux_secret_value(key_name, &safe_desc)
}
#[cfg(target_os = "macos")]
{
macos_secret_value(key_name, &safe_desc)
}
#[cfg(target_os = "windows")]
{
windows_secret_value(key_name, &safe_desc)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
#[cfg_attr(feature = "mock-gui", allow(unreachable_code, unused_variables))]
pub fn request_fido2_pin(
retries_left: u32,
attempt: u32,
) -> Result<zeroize::Zeroizing<String>, Error> {
#[cfg(feature = "mock-gui")]
{
assert_mock_gui_safe();
return mock::get_mock_fido2_pin().ok_or(Error::NoDisplay);
}
if !has_display() {
return Err(Error::NoDisplay);
}
#[cfg(target_os = "linux")]
{
linux_fido2_pin_entry(retries_left, attempt)
}
#[cfg(target_os = "macos")]
{
macos_fido2_pin_entry(retries_left, attempt)
}
#[cfg(target_os = "windows")]
{
windows_fido2_pin_entry(retries_left, attempt)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = (retries_left, attempt);
Err(Error::NoDisplay)
}
}
#[cfg_attr(feature = "mock-gui", allow(unreachable_code, unused_variables))]
pub fn request_totp_code(attempt: u32) -> Result<String, Error> {
#[cfg(feature = "mock-gui")]
{
assert_mock_gui_safe();
return mock::get_mock_totp().ok_or(Error::NoDisplay);
}
if !has_display() {
return Err(Error::NoDisplay);
}
#[cfg(target_os = "linux")]
{
linux_totp_entry(attempt)
}
#[cfg(target_os = "macos")]
{
macos_totp_entry(attempt)
}
#[cfg(target_os = "windows")]
{
windows_totp_entry(attempt)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
#[must_use]
pub fn is_interpreter(binary_path: &str) -> bool {
crate::guard::is_interpreter(binary_path)
}
pub(crate) fn sanitize_description(description: &str) -> String {
let filtered: String = description
.chars()
.filter(|c| {
!c.is_control()
&& !matches!(
*c,
'\u{202A}'
..='\u{202E}' | '\u{200E}' | '\u{200F}' )
})
.collect();
let stripped: String = filtered
.chars()
.filter(|c| !matches!(*c, '\u{0300}'..='\u{036F}'))
.collect();
let has_latin = stripped.chars().any(|c| c.is_ascii_alphabetic());
let has_confusable = stripped.chars().any(|c| {
matches!(
c as u32,
0x0400..=0x04FF | 0x0370..=0x03FF | 0x0530..=0x058F )
});
let sanitized: String = if has_latin && has_confusable {
stripped
.chars()
.filter(|c| {
!matches!(
*c as u32,
0x0400..=0x04FF | 0x0370..=0x03FF | 0x0530..=0x058F
)
})
.collect()
} else {
stripped
};
sanitized.chars().take(256).collect()
}
static CHALLENGE_PASSED_THIS_PROCESS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn challenge_already_passed_in_process() -> bool {
CHALLENGE_PASSED_THIS_PROCESS.load(std::sync::atomic::Ordering::Acquire)
}
fn mark_challenge_passed_in_process() {
CHALLENGE_PASSED_THIS_PROCESS.store(true, std::sync::atomic::Ordering::Release);
}
fn challenge_gate() -> Result<(), Error> {
let (challenge_text, expected) = guard::generate_gui_challenge();
#[cfg(target_os = "linux")]
{
let (dialog_path, kind) = resolve_linux_dialog()?;
let result = match kind {
DialogKind::Zenity => Command::new(&dialog_path)
.args([
"--entry",
"--title=envseal — Security Challenge",
&format!("--text=LOCKDOWN MODE\n\n{challenge_text}\n\nThis code confirms you are physically present."),
"--width=400",
])
.output(),
DialogKind::Kdialog => Command::new(&dialog_path)
.args([
"--inputbox",
&format!("LOCKDOWN: {challenge_text}"),
"--title",
"envseal — Security Challenge",
])
.output(),
};
match result {
Ok(output) if output.status.success() => {
let answer = String::from_utf8_lossy(&output.stdout).trim().to_string();
if guard::verify_gui_challenge(&expected, &answer) {
Ok(())
} else {
Err(Error::UserDenied)
}
}
_ => Err(Error::UserDenied),
}
}
#[cfg(target_os = "macos")]
{
let script = format!(
r#"display dialog "LOCKDOWN: {challenge_text}" with title "envseal — Security Challenge" default answer "" buttons {{"Cancel", "OK"}} default button "OK""#
);
let binary = guard::verify_gui_binary("osascript")
.unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
let result = Command::new(&binary).args(["-e", &script]).output();
match result {
Ok(output) if output.status.success() => {
let out = String::from_utf8_lossy(&output.stdout);
if let Some(answer) = out.strip_prefix("text returned:") {
if guard::verify_gui_challenge(&expected, answer.trim()) {
return Ok(());
}
}
Err(Error::UserDenied)
}
_ => Err(Error::UserDenied),
}
}
#[cfg(target_os = "windows")]
{
let script = format!(
r#"$input = [Microsoft.VisualBasic.Interaction]::InputBox("LOCKDOWN: {challenge_text}", "envseal — Security Challenge"); Write-Output $input"#
);
let result = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!("Add-Type -AssemblyName Microsoft.VisualBasic; {script}"),
])
.output();
match result {
Ok(output) if output.status.success() => {
let answer = String::from_utf8_lossy(&output.stdout).trim().to_string();
if guard::verify_gui_challenge(&expected, &answer) {
Ok(())
} else {
Err(Error::UserDenied)
}
}
_ => Err(Error::UserDenied),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
fn has_display() -> bool {
if std::env::var("ENVSEAL_DISABLE").is_ok_and(|v| !v.is_empty())
|| std::env::var("ENVSEAL_NO_GUI").is_ok_and(|v| !v.is_empty())
{
return false;
}
#[cfg(target_os = "linux")]
{
let display_ok = std::env::var("DISPLAY")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|d| {
let after_colon = d.split_once(':').map_or("", |(_, after)| after);
let n_str = after_colon.split('.').next().unwrap_or(after_colon);
if n_str.is_empty() {
return false;
}
std::path::Path::new(&format!("/tmp/.X11-unix/X{n_str}")).exists()
})
.unwrap_or(false);
let wayland_ok = std::env::var("WAYLAND_DISPLAY")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|wd| {
let p = std::path::PathBuf::from(&wd);
if p.is_absolute() {
p.exists()
} else if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
std::path::Path::new(&xdg).join(&wd).exists()
} else {
false
}
})
.unwrap_or(false);
display_ok || wayland_ok
}
#[cfg(target_os = "macos")]
{
std::env::var("__CFBundleIdentifier").is_ok()
|| std::env::var("XPC_FLAGS").is_ok_and(|v| !v.is_empty())
|| std::env::var("Apple_PubSub_Socket_Render").is_ok()
}
#[cfg(target_os = "windows")]
{
if std::env::var("SESSIONNAME")
.map(|s| s.trim().is_empty())
.unwrap_or(true)
{
return false;
}
unsafe {
use windows_sys::Win32::System::StationsAndDesktops::GetProcessWindowStation;
!GetProcessWindowStation().is_null()
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
false
}
}
fn enforce_rate_limit(config: &crate::security_config::SecurityConfig) -> Result<(), Error> {
let mut guard = RATE_LIMITER.lock().map_err(|_| {
Error::CryptoFailure("rate limiter state corrupted — retry after cooldown".to_string())
})?;
let state = &mut *guard;
let now = Instant::now();
if config.approval_cooldown_secs > 0 {
if let Some(last) = state.last_request {
let elapsed = now.duration_since(last);
if elapsed.as_secs() < u64::from(config.approval_cooldown_secs) {
let remaining = u64::from(config.approval_cooldown_secs) - elapsed.as_secs();
crate::audit::log_required(&crate::audit::AuditEvent::RateLimited {
reason: format!("cooldown: {remaining}s remaining"),
})?;
return Err(Error::CryptoFailure(format!(
"rate limited: approval cooldown ({remaining}s remaining). \
this prevents approval fatigue attacks."
)));
}
}
}
if config.max_approvals_per_minute > 0 {
let one_minute_ago = now
.checked_sub(std::time::Duration::from_secs(60))
.unwrap_or(now);
state.recent_requests.retain(|&t| t > one_minute_ago);
if state.recent_requests.len() >= config.max_approvals_per_minute as usize {
crate::audit::log_required(&crate::audit::AuditEvent::RateLimited {
reason: format!(
"per-minute cap: {} requests in last 60s (max={})",
state.recent_requests.len(),
config.max_approvals_per_minute
),
})?;
return Err(Error::CryptoFailure(format!(
"rate limited: {} approval requests in the last minute (max={}). \
this prevents approval fatigue attacks.",
state.recent_requests.len(),
config.max_approvals_per_minute
)));
}
}
state.last_request = Some(now);
state.recent_requests.push(now);
Ok(())
}
#[cfg(test)]
mod sanitize_description_tests {
use super::sanitize_description;
#[test]
fn strips_control_chars() {
let raw = "hello\x07\x08world";
let sanitized = sanitize_description(raw);
assert!(!sanitized.contains('\x07'));
assert!(!sanitized.contains('\x08'));
assert!(sanitized.contains("helloworld"));
}
#[test]
fn strips_bidi_overrides() {
let raw = "\u{202A}spoofed\u{202C}";
let sanitized = sanitize_description(raw);
assert!(!sanitized.contains('\u{202A}'));
assert!(!sanitized.contains('\u{202C}'));
assert!(sanitized.contains("spoofed"));
}
#[test]
fn strips_combining_marks() {
let raw = "a\u{0300}b\u{036F}c";
let sanitized = sanitize_description(raw);
assert!(!sanitized.contains('\u{0300}'));
assert!(!sanitized.contains('\u{036F}'));
assert_eq!(sanitized, "abc");
}
#[test]
fn caps_at_256_chars() {
let raw: String = (0..500).map(|_| 'x').collect();
let sanitized = sanitize_description(&raw);
assert_eq!(sanitized.len(), 256);
}
#[test]
fn preserves_safe_text() {
let raw = "Normal description: deploy to production";
let sanitized = sanitize_description(raw);
assert_eq!(sanitized, raw);
}
}