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}