tsafe-core 1.2.0

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
Documentation
//! Explicit deny reason codes for auditable exec policy enforcement.
//!
//! Every time an exec operation is denied (contract target check, missing required
//! secret, dangerous env variable, etc.), the deny is classified with a specific
//! reason code and message. This makes the audit log deterministic and helps operators
//! understand why a command was blocked.

use serde::{Deserialize, Serialize};
use std::fmt;

/// Specific reason why an exec operation was denied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum DenyReason {
    /// Contract target allowlist rejected the command (not in list, no match by
    /// exact name or basename).
    TargetNotAllowed,

    /// Command was empty or not provided, but the contract requires a target.
    TargetMissing,

    /// A secret in the contract's required_secrets list does not exist in the vault.
    RequiredSecretNotFound,

    /// An injected env var name is in the dangerous list (NODE_OPTIONS, LD_PRELOAD, etc.)
    /// and exec --deny-dangerous-env was active.
    DangerousEnvVariable,

    /// The vault profile specified in the contract does not exist or cannot be opened.
    VaultProfileNotFound,

    /// The namespace specified in the contract does not exist in the vault.
    NamespaceMissing,

    /// The access profile (read_only / read_write) cannot be enforced on this operation.
    AccessProfileViolation,

    /// A secret specified in allowed_secrets is not present in the vault.
    AllowedSecretNotFound,

    /// Network policy forbids this operation (future use).
    NetworkPolicyViolation,

    /// Implicit deny: no contract was provided and --keys/--ns were too restrictive.
    InsufficientAuthority,

    /// The bound contract cannot be found or parsed.
    MissingContract,

    /// Secret injection would be required, but the vault is locked.
    LockedVault,

    /// Secret injection would be required, but tsafe-agent is absent or unreachable.
    MissingAgent,

    /// The bound workdir is missing, invalid, unsafe, or not canonicalizable.
    BadWorkdir,

    /// A target, argument, or workdir-derived path escapes the authorized boundary.
    PathEscape,

    /// The request or contract would create an empty or ambient authority scope.
    BlankScope,

    /// The request attempts to override the startup-bound profile.
    ProfileOverride,

    /// The request attempts to override the startup-bound contract.
    ContractOverride,

    /// The request includes fields that widen authority at call time.
    RequestTimeWidening,

    /// The contract requests a network restriction that cannot be enforced.
    NetworkUnenforced,

    /// Required audit recording cannot be completed before execution.
    AuditUnavailable,

    /// Output exceeded the configured cap.
    OutputCap,

    /// Execution exceeded the configured timeout.
    Timeout,

    /// A requested host alias has no accepted stable config schema.
    HostSchemaUnstable,

    /// Generated or installed host config no longer matches the authority envelope.
    ConfigStale,

    /// Required proof or operator-visible evidence is unavailable.
    ProofUnavailable,

    /// Request JSON, contract data, or command arguments failed schema validation.
    ParseError,

    /// An unexpected implementation fault occurred after failing closed.
    InternalError,
}

impl DenyReason {
    /// Return a human-readable short message explaining the deny.
    pub fn message(&self) -> &'static str {
        match self {
            Self::TargetNotAllowed => "command not in contract target allowlist",
            Self::TargetMissing => "command required by contract but not provided",
            Self::RequiredSecretNotFound => "required vault secret does not exist",
            Self::DangerousEnvVariable => {
                "environment variable is high-risk; use --allow-dangerous-env to override"
            }
            Self::VaultProfileNotFound => "vault profile not found or cannot be opened",
            Self::NamespaceMissing => "namespace does not exist in vault",
            Self::AccessProfileViolation => {
                "operation violates access profile (read-only) constraint"
            }
            Self::AllowedSecretNotFound => "secret in contract allowlist does not exist in vault",
            Self::NetworkPolicyViolation => "operation violates network policy",
            Self::InsufficientAuthority => {
                "insufficient authority: add --keys or --ns or use a contract"
            }
            Self::MissingContract => "bound contract not found or could not be parsed",
            Self::LockedVault => "vault is locked and secret injection is required",
            Self::MissingAgent => "tsafe-agent is absent or unreachable",
            Self::BadWorkdir => "workdir is missing, invalid, unsafe, or not canonicalizable",
            Self::PathEscape => "path escapes the authorized boundary",
            Self::BlankScope => "request or contract would create an empty authority scope",
            Self::ProfileOverride => "request attempts to override the bound profile",
            Self::ContractOverride => "request attempts to override the bound contract",
            Self::RequestTimeWidening => "request attempts to widen authority at call time",
            Self::NetworkUnenforced => "network restriction cannot be enforced on this platform",
            Self::AuditUnavailable => "required audit recording is unavailable",
            Self::OutputCap => "output exceeded the configured cap",
            Self::Timeout => "execution exceeded the configured timeout",
            Self::HostSchemaUnstable => "host config schema is not stable for this alias",
            Self::ConfigStale => "host config no longer matches the authority envelope",
            Self::ProofUnavailable => "required proof evidence is unavailable",
            Self::ParseError => "request, contract, or command data failed validation",
            Self::InternalError => "internal error after failing closed",
        }
    }

    /// Return the reason code as a constant string.
    pub fn code(&self) -> &'static str {
        match self {
            Self::TargetNotAllowed => "TARGET_NOT_ALLOWED",
            Self::TargetMissing => "TARGET_MISSING",
            Self::RequiredSecretNotFound => "REQUIRED_SECRET_NOT_FOUND",
            Self::DangerousEnvVariable => "DANGEROUS_ENV_VARIABLE",
            Self::VaultProfileNotFound => "VAULT_PROFILE_NOT_FOUND",
            Self::NamespaceMissing => "NAMESPACE_MISSING",
            Self::AccessProfileViolation => "ACCESS_PROFILE_VIOLATION",
            Self::AllowedSecretNotFound => "ALLOWED_SECRET_NOT_FOUND",
            Self::NetworkPolicyViolation => "NETWORK_POLICY_VIOLATION",
            Self::InsufficientAuthority => "INSUFFICIENT_AUTHORITY",
            Self::MissingContract => "MISSING_CONTRACT",
            Self::LockedVault => "LOCKED_VAULT",
            Self::MissingAgent => "MISSING_AGENT",
            Self::BadWorkdir => "BAD_WORKDIR",
            Self::PathEscape => "PATH_ESCAPE",
            Self::BlankScope => "BLANK_SCOPE",
            Self::ProfileOverride => "PROFILE_OVERRIDE",
            Self::ContractOverride => "CONTRACT_OVERRIDE",
            Self::RequestTimeWidening => "REQUEST_TIME_WIDENING",
            Self::NetworkUnenforced => "NETWORK_UNENFORCED",
            Self::AuditUnavailable => "AUDIT_UNAVAILABLE",
            Self::OutputCap => "OUTPUT_CAP",
            Self::Timeout => "TIMEOUT",
            Self::HostSchemaUnstable => "HOST_SCHEMA_UNSTABLE",
            Self::ConfigStale => "CONFIG_STALE",
            Self::ProofUnavailable => "PROOF_UNAVAILABLE",
            Self::ParseError => "PARSE_ERROR",
            Self::InternalError => "INTERNAL_ERROR",
        }
    }
}

impl fmt::Display for DenyReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.code(), self.message())
    }
}

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

    #[test]
    fn deny_reasons_have_unique_codes() {
        let codes = [
            DenyReason::TargetNotAllowed.code(),
            DenyReason::TargetMissing.code(),
            DenyReason::RequiredSecretNotFound.code(),
            DenyReason::DangerousEnvVariable.code(),
            DenyReason::VaultProfileNotFound.code(),
            DenyReason::NamespaceMissing.code(),
            DenyReason::AccessProfileViolation.code(),
            DenyReason::AllowedSecretNotFound.code(),
            DenyReason::NetworkPolicyViolation.code(),
            DenyReason::InsufficientAuthority.code(),
            DenyReason::MissingContract.code(),
            DenyReason::LockedVault.code(),
            DenyReason::MissingAgent.code(),
            DenyReason::BadWorkdir.code(),
            DenyReason::PathEscape.code(),
            DenyReason::BlankScope.code(),
            DenyReason::ProfileOverride.code(),
            DenyReason::ContractOverride.code(),
            DenyReason::RequestTimeWidening.code(),
            DenyReason::NetworkUnenforced.code(),
            DenyReason::AuditUnavailable.code(),
            DenyReason::OutputCap.code(),
            DenyReason::Timeout.code(),
            DenyReason::HostSchemaUnstable.code(),
            DenyReason::ConfigStale.code(),
            DenyReason::ProofUnavailable.code(),
            DenyReason::ParseError.code(),
            DenyReason::InternalError.code(),
        ];
        assert_eq!(
            codes.len(),
            codes.iter().collect::<std::collections::HashSet<_>>().len()
        );
    }

    #[test]
    fn deny_reason_display_is_deterministic() {
        let reason = DenyReason::TargetNotAllowed;
        let display = format!("{reason}");
        assert_eq!(
            display,
            "TARGET_NOT_ALLOWED: command not in contract target allowlist"
        );
    }

    #[test]
    fn legacy_values_still_serialize_and_display_exactly() {
        assert_eq!(
            serde_json::to_string(&DenyReason::TargetNotAllowed).unwrap(),
            "\"TARGET_NOT_ALLOWED\""
        );
        assert_eq!(
            format!("{}", DenyReason::TargetNotAllowed),
            "TARGET_NOT_ALLOWED: command not in contract target allowlist"
        );
        assert_eq!(
            serde_json::to_string(&DenyReason::DangerousEnvVariable).unwrap(),
            "\"DANGEROUS_ENV_VARIABLE\""
        );
        assert_eq!(
            format!("{}", DenyReason::DangerousEnvVariable),
            "DANGEROUS_ENV_VARIABLE: environment variable is high-risk; use --allow-dangerous-env to override"
        );
    }

    #[test]
    fn new_variants_have_stable_codes() {
        let codes = [
            (DenyReason::MissingContract, "MISSING_CONTRACT"),
            (DenyReason::LockedVault, "LOCKED_VAULT"),
            (DenyReason::MissingAgent, "MISSING_AGENT"),
            (DenyReason::BadWorkdir, "BAD_WORKDIR"),
            (DenyReason::PathEscape, "PATH_ESCAPE"),
            (DenyReason::BlankScope, "BLANK_SCOPE"),
            (DenyReason::ProfileOverride, "PROFILE_OVERRIDE"),
            (DenyReason::ContractOverride, "CONTRACT_OVERRIDE"),
            (DenyReason::RequestTimeWidening, "REQUEST_TIME_WIDENING"),
            (DenyReason::NetworkUnenforced, "NETWORK_UNENFORCED"),
            (DenyReason::AuditUnavailable, "AUDIT_UNAVAILABLE"),
            (DenyReason::OutputCap, "OUTPUT_CAP"),
            (DenyReason::Timeout, "TIMEOUT"),
            (DenyReason::HostSchemaUnstable, "HOST_SCHEMA_UNSTABLE"),
            (DenyReason::ConfigStale, "CONFIG_STALE"),
            (DenyReason::ProofUnavailable, "PROOF_UNAVAILABLE"),
            (DenyReason::ParseError, "PARSE_ERROR"),
            (DenyReason::InternalError, "INTERNAL_ERROR"),
        ];

        for (reason, code) in codes {
            assert_eq!(reason.code(), code);
        }
    }
}