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`) — test and
18//! debug builds only; release builds ignore it
19//! 2. `shell_security` in the real user's `config.toml` (`config::shell_policy`)
20//! 3. default → [`ShellSecurity::Enforce`]
21//!
22//! The default is `enforce` on purpose: lean-ctx mediates the agent's shell, so
23//! defaulting to anything weaker would silently downgrade security for every
24//! existing install on upgrade. The redirect/“read-only output” doctrine in
25//! `validate_command` is a separate concern (MCP payload safety) and is NOT
26//! governed by this switch.
27
28/// Active shell-security posture. Order is least → most permissive only for
29/// readability; do not rely on ordinal values.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum ShellSecurity {
32 /// Block disallowed commands (today's behaviour). Secure-by-default.
33 #[default]
34 Enforce,
35 /// Run every check but only log violations — never block.
36 Warn,
37 /// Skip command gating entirely. Compression is unaffected.
38 Off,
39}
40
41impl ShellSecurity {
42 /// Parse a config/env value leniently. Returns `None` for unknown text so
43 /// the caller can fall back to the secure default instead of failing.
44 pub fn parse(value: &str) -> Option<Self> {
45 match value.trim().to_ascii_lowercase().as_str() {
46 "enforce" | "block" | "strict" | "on" => Some(Self::Enforce),
47 "warn" | "warn-only" | "warn_only" => Some(Self::Warn),
48 "off" | "disabled" | "none" | "yolo" => Some(Self::Off),
49 _ => None,
50 }
51 }
52
53 /// Canonical lower-case name (matches the config value).
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::Enforce => "enforce",
57 Self::Warn => "warn",
58 Self::Off => "off",
59 }
60 }
61
62 /// Resolve the active mode: env override → config → secure default.
63 pub fn resolve() -> Self {
64 // Release builds read neither the env var nor a relocated config:
65 // see `config::shell_policy`.
66 use crate::core::config::shell_policy;
67 if let Some(raw) = shell_policy::env("LEAN_CTX_SHELL_SECURITY")
68 && let Some(mode) = Self::parse(&raw)
69 {
70 return mode;
71 }
72 shell_policy::config()
73 .shell_security
74 .as_deref()
75 .and_then(Self::parse)
76 .unwrap_or_default()
77 }
78}
79
80impl std::fmt::Display for ShellSecurity {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.write_str(self.as_str())
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn parse_accepts_canonical_and_aliases() {
92 assert_eq!(
93 ShellSecurity::parse("enforce"),
94 Some(ShellSecurity::Enforce)
95 );
96 assert_eq!(ShellSecurity::parse("ON"), Some(ShellSecurity::Enforce));
97 assert_eq!(ShellSecurity::parse(" Warn "), Some(ShellSecurity::Warn));
98 assert_eq!(ShellSecurity::parse("off"), Some(ShellSecurity::Off));
99 assert_eq!(ShellSecurity::parse("yolo"), Some(ShellSecurity::Off));
100 }
101
102 #[test]
103 fn parse_rejects_unknown_so_caller_can_default() {
104 assert_eq!(ShellSecurity::parse("loose"), None);
105 assert_eq!(ShellSecurity::parse(""), None);
106 }
107
108 #[test]
109 fn default_is_enforce() {
110 assert_eq!(ShellSecurity::default(), ShellSecurity::Enforce);
111 }
112
113 #[test]
114 fn as_str_roundtrips_through_parse() {
115 for mode in [
116 ShellSecurity::Enforce,
117 ShellSecurity::Warn,
118 ShellSecurity::Off,
119 ] {
120 assert_eq!(ShellSecurity::parse(mode.as_str()), Some(mode));
121 }
122 }
123
124 #[test]
125 fn env_override_takes_precedence_over_config() {
126 // Serialize env access through the shared test lock so this never races
127 // other env-reading tests (the qubo_select lesson).
128 let _lock = crate::core::data_dir::test_env_lock();
129 crate::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "off");
130 assert_eq!(ShellSecurity::resolve(), ShellSecurity::Off);
131 crate::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "garbage");
132 // Unknown env value → fall through (config/default), never panics.
133 let resolved = ShellSecurity::resolve();
134 assert!(matches!(
135 resolved,
136 ShellSecurity::Enforce | ShellSecurity::Warn | ShellSecurity::Off
137 ));
138 crate::test_env::remove_var("LEAN_CTX_SHELL_SECURITY");
139 }
140}