Skip to main content

lean_ctx/core/
security_posture.rs

1//! Unified, human-facing view of lean-ctx's **two independent security planes**:
2//!
3//! 1. **Containment** — the path jail + shell-command gating. Protects *the
4//!    machine from the agent* (what files a tool may touch, what binaries the
5//!    shell may run).
6//! 2. **Secret-exfiltration defense** — secret/`.env` redaction. Protects *your
7//!    secrets from the LLM provider* (API keys masked before they reach the
8//!    model).
9//!
10//! These are orthogonal by design: a usability-first user can drop containment
11//! (`lean-ctx yolo`) while still never leaking credentials to the provider, and
12//! vice-versa. This module is the single source of truth both the
13//! `lean-ctx security` command and `lean-ctx doctor` read from, so the CLI
14//! status screen and the doctor board can never disagree.
15//!
16//! It is a **pure read** of config + env (no side effects), which keeps it cheap
17//! to call and safe to use inside deterministic output paths.
18
19use crate::core::config::Config;
20use crate::core::shell_allowlist::ShellSecurity;
21use std::path::Path;
22
23/// Effective state of the filesystem path jail.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum JailState {
26    /// Fully enforced: tools are confined to the project root (+ any configured
27    /// `allow_paths`/`extra_roots`), with no blanket relaxation active.
28    Enforced,
29    /// Enforced, but widened by one or more knobs (e.g. `LEAN_CTX_ALLOW_PATH`,
30    /// `extra_roots`, IDE-config dirs). Carries the source labels for display.
31    Relaxed(Vec<String>),
32    /// Disabled outright — every tool path is allowed. Carries the knob that
33    /// turned it off (`path_jail = false`, the `no-jail` build, or
34    /// `allow_paths = ["/"]`).
35    Disabled(String),
36}
37
38impl JailState {
39    /// True when containment over the filesystem is effectively gone.
40    #[must_use]
41    pub fn is_disabled(&self) -> bool {
42        matches!(self, JailState::Disabled(_))
43    }
44}
45
46/// Coarse, derived label summarising the whole posture for at-a-glance display.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PostureLevel {
49    /// Both containment planes fully enforced (the secure default).
50    Strict,
51    /// Partially relaxed (e.g. jail widened, or shell in `warn`).
52    Relaxed,
53    /// Containment fully off (`yolo`) — jail disabled *and* shell gating off.
54    Open,
55}
56
57impl PostureLevel {
58    /// Lower-case, stable name (used in status output and tests).
59    #[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/// A snapshot of every security-relevant switch, resolved exactly the way the
70/// runtime enforces it (env → config → secure default).
71#[derive(Debug, Clone)]
72pub struct SecurityPosture {
73    /// Filesystem path-jail state.
74    pub jail: JailState,
75    /// Shell-command gating mode.
76    pub shell: ShellSecurity,
77    /// Whether secret/`.env` detection runs on tool output.
78    pub secrets_enabled: bool,
79    /// Whether detected secrets are actually masked (vs only flagged).
80    pub secrets_redact: bool,
81}
82
83impl SecurityPosture {
84    /// Resolve the live posture from config + env. Pure read, no side effects.
85    #[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    /// Derived coarse label. `Open` only when *both* containment planes are off,
97    /// so it precisely reflects what `lean-ctx yolo` produces.
98    #[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    /// True when secrets still cannot leak to the provider (detection + masking
114    /// both on). This stays independent of [`Self::level`] on purpose.
115    #[must_use]
116    pub fn secrets_protected(&self) -> bool {
117        self.secrets_enabled && self.secrets_redact
118    }
119}
120
121/// Mirror of the precedence in `pathjail` + the doctor `path_jail_outcome`, kept
122/// here as the single classifier so CLI and doctor agree on what "disabled"
123/// means.
124fn 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    // `allow_paths`/`extra_roots` containing "/" is a prefix of everything, so
132    // it grants blanket access just like `path_jail = false` (GH #392).
133    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    // Remaining relaxations (env channels, IDE-config dirs) only *widen* the
143    // jail; the full-disable sources above are handled by the early returns.
144    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        // Jail off but shell still enforcing → not fully open.
193        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        // Shell off but jail enforced → not fully open.
202        let shell_only = posture(JailState::Enforced, ShellSecurity::Off, true, true);
203        assert_eq!(shell_only.level(), PostureLevel::Relaxed);
204
205        // Jail merely widened (not disabled) → relaxed.
206        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        // Fully open containment, yet secrets are still protected.
218        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        // Strict containment, yet redaction explicitly turned off.
228        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}