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    /// Bound to a launcher Workspace (#2120): only its roots, regardless of
37    /// every widening knob and off-switch. Carries the binding for display.
38    Workspace(String),
39}
40
41impl JailState {
42    /// True when containment over the filesystem is effectively gone.
43    #[must_use]
44    pub fn is_disabled(&self) -> bool {
45        matches!(self, JailState::Disabled(_))
46    }
47}
48
49/// Coarse, derived label summarising the whole posture for at-a-glance display.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PostureLevel {
52    /// Both containment planes fully enforced (the secure default).
53    Strict,
54    /// Partially relaxed (e.g. jail widened, or shell in `warn`).
55    Relaxed,
56    /// Containment fully off (`yolo`) — jail disabled *and* shell gating off.
57    Open,
58}
59
60impl PostureLevel {
61    /// Lower-case, stable name (used in status output and tests).
62    #[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/// A snapshot of every security-relevant switch, resolved exactly the way the
73/// runtime enforces it (env → config → secure default).
74#[derive(Debug, Clone)]
75pub struct SecurityPosture {
76    /// Filesystem path-jail state.
77    pub jail: JailState,
78    /// Shell-command gating mode.
79    pub shell: ShellSecurity,
80    /// Whether secret/`.env` detection runs on tool output.
81    pub secrets_enabled: bool,
82    /// Whether detected secrets are actually masked (vs only flagged).
83    pub secrets_redact: bool,
84}
85
86impl SecurityPosture {
87    /// Resolve the live posture from config + env. Pure read, no side effects.
88    #[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    /// Derived coarse label. `Open` only when *both* containment planes are off,
100    /// so it precisely reflects what `lean-ctx yolo` produces.
101    #[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    /// True when secrets still cannot leak to the provider (detection + masking
117    /// both on). This stays independent of [`Self::level`] on purpose.
118    #[must_use]
119    pub fn secrets_protected(&self) -> bool {
120        self.secrets_enabled && self.secrets_redact
121    }
122}
123
124/// Mirror of the precedence in `pathjail` + the doctor `path_jail_outcome`, kept
125/// here as the single classifier so CLI and doctor agree on what "disabled"
126/// means.
127fn 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    // `allow_paths`/`extra_roots` containing "/" is a prefix of everything, so
147    // it grants blanket access just like `path_jail = false` (GH #392).
148    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    // Remaining relaxations (env channels, IDE-config dirs) only *widen* the
158    // jail; the full-disable sources above are handled by the early returns.
159    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        // Jail off but shell still enforcing → not fully open.
208        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        // Shell off but jail enforced → not fully open.
217        let shell_only = posture(JailState::Enforced, ShellSecurity::Off, true, true);
218        assert_eq!(shell_only.level(), PostureLevel::Relaxed);
219
220        // Jail merely widened (not disabled) → relaxed.
221        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        // Fully open containment, yet secrets are still protected.
233        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        // Strict containment, yet redaction explicitly turned off.
243        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}