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 Workspace(String),
39}
40
41impl JailState {
42 #[must_use]
44 pub fn is_disabled(&self) -> bool {
45 matches!(self, JailState::Disabled(_))
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PostureLevel {
52 Strict,
54 Relaxed,
56 Open,
58}
59
60impl PostureLevel {
61 #[must_use]
63 pub fn as_str(self) -> &'static str {
64 match self {
65 PostureLevel::Strict => "strict",
66 PostureLevel::Relaxed => "relaxed",
67 PostureLevel::Open => "open",
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
75pub struct SecurityPosture {
76 pub jail: JailState,
78 pub shell: ShellSecurity,
80 pub secrets_enabled: bool,
82 pub secrets_redact: bool,
84}
85
86impl SecurityPosture {
87 #[must_use]
89 pub fn detect() -> Self {
90 let cfg = Config::load();
91 Self {
92 jail: detect_jail(&cfg),
93 shell: ShellSecurity::resolve(),
94 secrets_enabled: cfg.secret_detection.enabled,
95 secrets_redact: cfg.secret_detection.redact,
96 }
97 }
98
99 #[must_use]
102 pub fn level(&self) -> PostureLevel {
103 let containment_off = self.jail.is_disabled() && self.shell == ShellSecurity::Off;
104 if containment_off {
105 return PostureLevel::Open;
106 }
107 let strict = matches!(self.jail, JailState::Enforced | JailState::Workspace(_))
108 && self.shell == ShellSecurity::Enforce;
109 if strict {
110 PostureLevel::Strict
111 } else {
112 PostureLevel::Relaxed
113 }
114 }
115
116 #[must_use]
119 pub fn secrets_protected(&self) -> bool {
120 self.secrets_enabled && self.secrets_redact
121 }
122}
123
124fn detect_jail(cfg: &Config) -> JailState {
128 match crate::core::workspace_authority::load_current() {
129 crate::core::workspace_authority::Authority::Legacy => {}
130 crate::core::workspace_authority::Authority::Workspace(snapshot) => {
131 return JailState::Workspace(format!(
132 "Workspace {} rev {}",
133 snapshot.id, snapshot.revision
134 ));
135 }
136 crate::core::workspace_authority::Authority::Unavailable(_) => {
137 return JailState::Workspace("Workspace unavailable — every path denied".to_string());
138 }
139 }
140 if cfg!(feature = "no-jail") {
141 return JailState::Disabled("no-jail build feature".to_string());
142 }
143 if cfg.path_jail == Some(false) {
144 return JailState::Disabled("path_jail = false".to_string());
145 }
146 let grants_everything = cfg
149 .allow_paths
150 .iter()
151 .chain(cfg.extra_roots.iter())
152 .any(|raw| crate::core::pathjail::expand_user_path(raw) == Path::new("/"));
153 if grants_everything {
154 return JailState::Disabled("allow_paths contains \"/\"".to_string());
155 }
156
157 let relaxed: Vec<String> = crate::core::pathjail::active_relaxations()
160 .into_iter()
161 .map(|r| r.source.to_string())
162 .collect();
163 if relaxed.is_empty() {
164 JailState::Enforced
165 } else {
166 JailState::Relaxed(relaxed)
167 }
168}
169
170#[cfg(test)]
171pub mod tests {
172 use super::*;
173
174 fn posture(
175 jail: JailState,
176 shell: ShellSecurity,
177 secrets: bool,
178 redact: bool,
179 ) -> SecurityPosture {
180 SecurityPosture {
181 jail,
182 shell,
183 secrets_enabled: secrets,
184 secrets_redact: redact,
185 }
186 }
187
188 #[test]
189 fn level_strict_when_both_planes_enforced() {
190 let p = posture(JailState::Enforced, ShellSecurity::Enforce, true, true);
191 assert_eq!(p.level(), PostureLevel::Strict);
192 }
193
194 #[test]
195 fn level_open_only_when_jail_disabled_and_shell_off() {
196 let p = posture(
197 JailState::Disabled("path_jail = false".into()),
198 ShellSecurity::Off,
199 true,
200 true,
201 );
202 assert_eq!(p.level(), PostureLevel::Open);
203 }
204
205 #[test]
206 fn level_relaxed_when_only_one_plane_dropped() {
207 let jail_only = posture(
209 JailState::Disabled("path_jail = false".into()),
210 ShellSecurity::Enforce,
211 true,
212 true,
213 );
214 assert_eq!(jail_only.level(), PostureLevel::Relaxed);
215
216 let shell_only = posture(JailState::Enforced, ShellSecurity::Off, true, true);
218 assert_eq!(shell_only.level(), PostureLevel::Relaxed);
219
220 let widened = posture(
222 JailState::Relaxed(vec!["LEAN_CTX_ALLOW_PATH".into()]),
223 ShellSecurity::Enforce,
224 true,
225 true,
226 );
227 assert_eq!(widened.level(), PostureLevel::Relaxed);
228 }
229
230 #[test]
231 fn secrets_protection_is_independent_of_containment() {
232 let open_but_secret_safe = posture(
234 JailState::Disabled("path_jail = false".into()),
235 ShellSecurity::Off,
236 true,
237 true,
238 );
239 assert_eq!(open_but_secret_safe.level(), PostureLevel::Open);
240 assert!(open_but_secret_safe.secrets_protected());
241
242 let strict_but_leaky = posture(JailState::Enforced, ShellSecurity::Enforce, false, true);
244 assert_eq!(strict_but_leaky.level(), PostureLevel::Strict);
245 assert!(!strict_but_leaky.secrets_protected());
246 }
247
248 #[test]
249 fn jail_state_is_disabled_helper() {
250 assert!(JailState::Disabled("x".into()).is_disabled());
251 assert!(!JailState::Enforced.is_disabled());
252 assert!(!JailState::Relaxed(vec!["y".into()]).is_disabled());
253 }
254
255 #[test]
256 fn posture_level_names_are_stable() {
257 assert_eq!(PostureLevel::Strict.as_str(), "strict");
258 assert_eq!(PostureLevel::Relaxed.as_str(), "relaxed");
259 assert_eq!(PostureLevel::Open.as_str(), "open");
260 }
261}