lean_ctx/core/
security_posture.rs1use crate::core::config::Config;
20use crate::core::shell_allowlist::ShellSecurity;
21use std::path::Path;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum JailState {
26 Enforced,
29 Relaxed(Vec<String>),
32 Disabled(String),
36}
37
38impl JailState {
39 #[must_use]
41 pub fn is_disabled(&self) -> bool {
42 matches!(self, JailState::Disabled(_))
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PostureLevel {
49 Strict,
51 Relaxed,
53 Open,
55}
56
57impl PostureLevel {
58 #[must_use]
60 pub fn as_str(self) -> &'static str {
61 match self {
62 PostureLevel::Strict => "strict",
63 PostureLevel::Relaxed => "relaxed",
64 PostureLevel::Open => "open",
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
72pub struct SecurityPosture {
73 pub jail: JailState,
75 pub shell: ShellSecurity,
77 pub secrets_enabled: bool,
79 pub secrets_redact: bool,
81}
82
83impl SecurityPosture {
84 #[must_use]
86 pub fn detect() -> Self {
87 let cfg = Config::load();
88 Self {
89 jail: detect_jail(&cfg),
90 shell: ShellSecurity::resolve(),
91 secrets_enabled: cfg.secret_detection.enabled,
92 secrets_redact: cfg.secret_detection.redact,
93 }
94 }
95
96 #[must_use]
99 pub fn level(&self) -> PostureLevel {
100 let containment_off = self.jail.is_disabled() && self.shell == ShellSecurity::Off;
101 if containment_off {
102 return PostureLevel::Open;
103 }
104 let strict =
105 matches!(self.jail, JailState::Enforced) && self.shell == ShellSecurity::Enforce;
106 if strict {
107 PostureLevel::Strict
108 } else {
109 PostureLevel::Relaxed
110 }
111 }
112
113 #[must_use]
116 pub fn secrets_protected(&self) -> bool {
117 self.secrets_enabled && self.secrets_redact
118 }
119}
120
121fn detect_jail(cfg: &Config) -> JailState {
125 if cfg!(feature = "no-jail") {
126 return JailState::Disabled("no-jail build feature".to_string());
127 }
128 if cfg.path_jail == Some(false) {
129 return JailState::Disabled("path_jail = false".to_string());
130 }
131 let grants_everything = cfg
134 .allow_paths
135 .iter()
136 .chain(cfg.extra_roots.iter())
137 .any(|raw| crate::core::pathjail::expand_user_path(raw) == Path::new("/"));
138 if grants_everything {
139 return JailState::Disabled("allow_paths contains \"/\"".to_string());
140 }
141
142 let relaxed: Vec<String> = crate::core::pathjail::active_relaxations()
145 .into_iter()
146 .map(|r| r.source.to_string())
147 .collect();
148 if relaxed.is_empty() {
149 JailState::Enforced
150 } else {
151 JailState::Relaxed(relaxed)
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn posture(
160 jail: JailState,
161 shell: ShellSecurity,
162 secrets: bool,
163 redact: bool,
164 ) -> SecurityPosture {
165 SecurityPosture {
166 jail,
167 shell,
168 secrets_enabled: secrets,
169 secrets_redact: redact,
170 }
171 }
172
173 #[test]
174 fn level_strict_when_both_planes_enforced() {
175 let p = posture(JailState::Enforced, ShellSecurity::Enforce, true, true);
176 assert_eq!(p.level(), PostureLevel::Strict);
177 }
178
179 #[test]
180 fn level_open_only_when_jail_disabled_and_shell_off() {
181 let p = posture(
182 JailState::Disabled("path_jail = false".into()),
183 ShellSecurity::Off,
184 true,
185 true,
186 );
187 assert_eq!(p.level(), PostureLevel::Open);
188 }
189
190 #[test]
191 fn level_relaxed_when_only_one_plane_dropped() {
192 let jail_only = posture(
194 JailState::Disabled("path_jail = false".into()),
195 ShellSecurity::Enforce,
196 true,
197 true,
198 );
199 assert_eq!(jail_only.level(), PostureLevel::Relaxed);
200
201 let shell_only = posture(JailState::Enforced, ShellSecurity::Off, true, true);
203 assert_eq!(shell_only.level(), PostureLevel::Relaxed);
204
205 let widened = posture(
207 JailState::Relaxed(vec!["LEAN_CTX_ALLOW_PATH".into()]),
208 ShellSecurity::Enforce,
209 true,
210 true,
211 );
212 assert_eq!(widened.level(), PostureLevel::Relaxed);
213 }
214
215 #[test]
216 fn secrets_protection_is_independent_of_containment() {
217 let open_but_secret_safe = posture(
219 JailState::Disabled("path_jail = false".into()),
220 ShellSecurity::Off,
221 true,
222 true,
223 );
224 assert_eq!(open_but_secret_safe.level(), PostureLevel::Open);
225 assert!(open_but_secret_safe.secrets_protected());
226
227 let strict_but_leaky = posture(JailState::Enforced, ShellSecurity::Enforce, false, true);
229 assert_eq!(strict_but_leaky.level(), PostureLevel::Strict);
230 assert!(!strict_but_leaky.secrets_protected());
231 }
232
233 #[test]
234 fn jail_state_is_disabled_helper() {
235 assert!(JailState::Disabled("x".into()).is_disabled());
236 assert!(!JailState::Enforced.is_disabled());
237 assert!(!JailState::Relaxed(vec!["y".into()]).is_disabled());
238 }
239
240 #[test]
241 fn posture_level_names_are_stable() {
242 assert_eq!(PostureLevel::Strict.as_str(), "strict");
243 assert_eq!(PostureLevel::Relaxed.as_str(), "relaxed");
244 assert_eq!(PostureLevel::Open.as_str(), "open");
245 }
246}