Skip to main content

lean_ctx/core/shell_allowlist/
mode.rs

1//! Shell-security mode — the master switch for ctx_shell / `lean-ctx -c` command
2//! gating (GL #788).
3//!
4//! Three levels, applied at the single chokepoint
5//! [`check_shell_allowlist`](super::check_shell_allowlist) so MCP and CLI behave
6//! identically:
7//!
8//! - `enforce` (**default**, secure-by-default): the allowlist and the
9//!   unconditional/dangerous-pattern blocks are enforced — today's behaviour.
10//! - `warn`: the same checks run but a violation is only logged (tracing),
11//!   never blocked.
12//! - `off`: command gating is skipped entirely (allowlist, dangerous patterns,
13//!   `eval`/`exec`/interpreter `-c`). A deliberate, documented opt-out for power
14//!   users who accept the risk — **compression stays fully active**.
15//!
16//! Resolution precedence (first hit wins):
17//! 1. `LEAN_CTX_SHELL_SECURITY` env (`enforce` | `warn` | `off`)
18//! 2. `shell_security` in `config.toml`
19//! 3. default → [`ShellSecurity::Enforce`]
20//!
21//! The default is `enforce` on purpose: lean-ctx mediates the agent's shell, so
22//! defaulting to anything weaker would silently downgrade security for every
23//! existing install on upgrade. The redirect/“read-only output” doctrine in
24//! `validate_command` is a separate concern (MCP payload safety) and is NOT
25//! governed by this switch.
26
27/// Active shell-security posture. Order is least → most permissive only for
28/// readability; do not rely on ordinal values.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum ShellSecurity {
31    /// Block disallowed commands (today's behaviour). Secure-by-default.
32    #[default]
33    Enforce,
34    /// Run every check but only log violations — never block.
35    Warn,
36    /// Skip command gating entirely. Compression is unaffected.
37    Off,
38}
39
40impl ShellSecurity {
41    /// Parse a config/env value leniently. Returns `None` for unknown text so
42    /// the caller can fall back to the secure default instead of failing.
43    pub fn parse(value: &str) -> Option<Self> {
44        match value.trim().to_ascii_lowercase().as_str() {
45            "enforce" | "block" | "strict" | "on" => Some(Self::Enforce),
46            "warn" | "warn-only" | "warn_only" => Some(Self::Warn),
47            "off" | "disabled" | "none" | "yolo" => Some(Self::Off),
48            _ => None,
49        }
50    }
51
52    /// Canonical lower-case name (matches the config value).
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::Enforce => "enforce",
56            Self::Warn => "warn",
57            Self::Off => "off",
58        }
59    }
60
61    /// Resolve the active mode: env override → config → secure default.
62    pub fn resolve() -> Self {
63        if let Ok(raw) = std::env::var("LEAN_CTX_SHELL_SECURITY")
64            && let Some(mode) = Self::parse(&raw)
65        {
66            return mode;
67        }
68        crate::core::config::Config::load()
69            .shell_security
70            .as_deref()
71            .and_then(Self::parse)
72            .unwrap_or_default()
73    }
74}
75
76impl std::fmt::Display for ShellSecurity {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.write_str(self.as_str())
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn parse_accepts_canonical_and_aliases() {
88        assert_eq!(
89            ShellSecurity::parse("enforce"),
90            Some(ShellSecurity::Enforce)
91        );
92        assert_eq!(ShellSecurity::parse("ON"), Some(ShellSecurity::Enforce));
93        assert_eq!(ShellSecurity::parse(" Warn "), Some(ShellSecurity::Warn));
94        assert_eq!(ShellSecurity::parse("off"), Some(ShellSecurity::Off));
95        assert_eq!(ShellSecurity::parse("yolo"), Some(ShellSecurity::Off));
96    }
97
98    #[test]
99    fn parse_rejects_unknown_so_caller_can_default() {
100        assert_eq!(ShellSecurity::parse("loose"), None);
101        assert_eq!(ShellSecurity::parse(""), None);
102    }
103
104    #[test]
105    fn default_is_enforce() {
106        assert_eq!(ShellSecurity::default(), ShellSecurity::Enforce);
107    }
108
109    #[test]
110    fn as_str_roundtrips_through_parse() {
111        for mode in [
112            ShellSecurity::Enforce,
113            ShellSecurity::Warn,
114            ShellSecurity::Off,
115        ] {
116            assert_eq!(ShellSecurity::parse(mode.as_str()), Some(mode));
117        }
118    }
119
120    #[test]
121    fn env_override_takes_precedence_over_config() {
122        // Serialize env access through the shared test lock so this never races
123        // other env-reading tests (the qubo_select lesson).
124        let _lock = crate::core::data_dir::test_env_lock();
125        crate::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "off");
126        assert_eq!(ShellSecurity::resolve(), ShellSecurity::Off);
127        crate::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "garbage");
128        // Unknown env value → fall through (config/default), never panics.
129        let resolved = ShellSecurity::resolve();
130        assert!(matches!(
131            resolved,
132            ShellSecurity::Enforce | ShellSecurity::Warn | ShellSecurity::Off
133        ));
134        crate::test_env::remove_var("LEAN_CTX_SHELL_SECURITY");
135    }
136}