Skip to main content

lean_ctx/core/config/
shell_policy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Where shell-security decisions come from.
3//!
4//! The shell gate (`check_shell_allowlist`, run by the hooks, inside
5//! `lean-ctx -c` / `-t` and by `ctx_shell`) decides what an agent's shell may
6//! run. A command line from that shell can set environment variables for the
7//! lean-ctx it starts (`export`, `env -S`, `set -a`, `declare -n`, `printf -v`,
8//! names built by expansion, …) and can write files under any directory it
9//! names in `HOME`, `XDG_CONFIG_HOME` or `LEAN_CTX_CONFIG_DIR`. Blocking each
10//! of those spellings is a race the gate cannot win. So in release builds the
11//! policy that can loosen the gate is read only from places a command line
12//! cannot move:
13//!
14//! - the compiled defaults;
15//! - the global `config.toml` of the user this process runs as, at that
16//!   user's home as the operating system records it (the passwd database on
17//!   Unix, the profile folder from `SHGetKnownFolderPath` on Windows) — never
18//!   `$HOME`, `%USERPROFILE%`, `XDG_CONFIG_HOME`, `LEAN_CTX_CONFIG_DIR` or
19//!   `LEAN_CTX_DATA_DIR`;
20//! - a project's `.lean-ctx.toml`, for a workspace trusted in the trust store
21//!   next to that config (the trust environment variables are ignored).
22//!
23//! No `LEAN_CTX_SHELL_*` (or other gate-loosening) environment variable is
24//! read for these decisions.
25//!
26//! Trade-off: someone who relocates lean-ctx's config with
27//! `LEAN_CTX_CONFIG_DIR` (or a non-default `XDG_CONFIG_HOME`) keeps every
28//! other setting there, but the shell-security settings (`shell_security`,
29//! `shell_allowlist`, `shell_allowlist_extra`, `shell_strict_mode`,
30//! `shell_allow_inline_scripts`, `shell_allow_writes`, `shell_approval`,
31//! `shell_hook_mode`, `shell_allowlist_subcommand_scoping`) are read from the
32//! default location in the real home. Without a file there the secure
33//! defaults apply.
34//!
35//! Tests and debug builds (`cfg(test)` / `debug_assertions`) keep the ambient
36//! resolution (`Config::load` plus the environment variables) so the test
37//! suites stay hermetic through `isolated_data_dir` and the env helpers. A
38//! unit test pins the release resolution with `pin_for_test`.
39
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex};
42
43use super::{Config, ShellApproval};
44
45/// The source the gate reads its policy from.
46enum Source {
47    /// `Config::load()` and the environment: test and debug builds only.
48    #[cfg_attr(not(any(test, debug_assertions)), allow(dead_code))]
49    Ambient,
50    /// The real user's home (`None`: unresolvable, so built-in defaults).
51    /// Debug builds outside tests never construct it.
52    #[cfg_attr(all(debug_assertions, not(test)), allow(dead_code))]
53    Pinned(Option<PathBuf>),
54}
55
56#[cfg(any(test, feature = "test-support"))]
57static TEST_PIN: Mutex<Option<PathBuf>> = Mutex::new(None);
58
59fn source() -> Source {
60    #[cfg(any(test, feature = "test-support"))]
61    {
62        if let Some(home) = TEST_PIN
63            .lock()
64            .unwrap_or_else(std::sync::PoisonError::into_inner)
65            .clone()
66        {
67            return Source::Pinned(Some(home));
68        }
69    }
70    #[cfg(any(test, debug_assertions))]
71    {
72        Source::Ambient
73    }
74    #[cfg(not(any(test, debug_assertions)))]
75    {
76        Source::Pinned(real_user_home())
77    }
78}
79
80/// Test-only: resolve the policy as a release build does, with `home`
81/// standing in for the passwd/profile home. Hold the test env lock while the
82/// guard lives; dropping it restores the ambient resolution.
83#[cfg(any(test, feature = "test-support"))]
84#[must_use]
85pub fn pin_for_test(home: &Path) -> PinGuard {
86    assert!(
87        crate::core::data_dir::holds_test_env_lock(),
88        "pin_for_test requires test_env_lock()"
89    );
90    *TEST_PIN
91        .lock()
92        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(home.to_path_buf());
93    PinGuard(())
94}
95
96/// Restores the ambient policy source when dropped.
97#[cfg(any(test, feature = "test-support"))]
98pub struct PinGuard(());
99
100#[cfg(any(test, feature = "test-support"))]
101impl Drop for PinGuard {
102    fn drop(&mut self) {
103        *TEST_PIN
104            .lock()
105            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
106    }
107}
108
109/// True when the gate reads the pinned (release) policy.
110#[must_use]
111pub fn pinned() -> bool {
112    matches!(source(), Source::Pinned(_))
113}
114
115/// The home directory of the user this process runs as, from the operating
116/// system's account database — not from `$HOME`.
117#[cfg(unix)]
118#[must_use]
119pub fn real_user_home() -> Option<PathBuf> {
120    use std::ffi::{CStr, OsStr};
121    use std::os::unix::ffi::OsStrExt;
122
123    // SAFETY: geteuid has no arguments or failure state.
124    let uid = unsafe { libc::geteuid() };
125    let mut buf = vec![0_u8; 4096];
126    loop {
127        // SAFETY: `passwd` is a plain C struct; all-zero is a valid value
128        // that getpwuid_r overwrites.
129        let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
130        let mut result: *mut libc::passwd = std::ptr::null_mut();
131        // SAFETY: every pointer refers to live, writable memory of the
132        // advertised size for the duration of the call.
133        let rc = unsafe {
134            libc::getpwuid_r(
135                uid,
136                &raw mut pwd,
137                buf.as_mut_ptr().cast(),
138                buf.len(),
139                &raw mut result,
140            )
141        };
142        if rc == libc::ERANGE && buf.len() < (1 << 20) {
143            buf.resize(buf.len() * 2, 0);
144            continue;
145        }
146        if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
147            return None;
148        }
149        // SAFETY: on success pw_dir points to a NUL-terminated string inside
150        // `buf`, which outlives this borrow.
151        let dir = unsafe { CStr::from_ptr(pwd.pw_dir) };
152        let path = PathBuf::from(OsStr::from_bytes(dir.to_bytes()));
153        return (path.is_absolute()).then_some(path);
154    }
155}
156
157/// The home directory of the user this process runs as. `dirs` asks
158/// `SHGetKnownFolderPath(FOLDERID_Profile)` on Windows and never reads
159/// `%USERPROFILE%` or `%HOME%`.
160#[cfg(not(unix))]
161#[must_use]
162pub fn real_user_home() -> Option<PathBuf> {
163    dirs::home_dir().filter(|p| p.is_absolute())
164}
165
166/// lean-ctx's config directory inside `home`, laid out as an install without
167/// any directory override resolves it (legacy `~/.lean-ctx` or a pre-split
168/// `~/.config/lean-ctx` still count; otherwise `~/.config/lean-ctx`).
169fn config_dir_in(home: &Path) -> PathBuf {
170    let xdg_config = home.join(".config");
171    let xdg_data = home.join(".local").join("share");
172    crate::core::paths::single_dir_override_fs(home, &xdg_config, Some(&xdg_data))
173        .unwrap_or_else(|| xdg_config.join("lean-ctx"))
174}
175
176/// The global `config.toml` the gate reads its policy from, for messages.
177#[must_use]
178pub fn config_path() -> Option<PathBuf> {
179    match source() {
180        Source::Ambient => Config::path(),
181        Source::Pinned(home) => home.map(|h| config_dir_in(&h).join("config.toml")),
182    }
183}
184
185/// The home whose `.claude/settings.json` the gate may mirror.
186#[must_use]
187pub fn settings_home() -> Option<PathBuf> {
188    match source() {
189        Source::Ambient => crate::core::home::resolve_home_dir(),
190        Source::Pinned(home) => home,
191    }
192}
193
194type PinnedCache = Option<(String, Arc<Config>)>;
195
196/// The configuration shell-security decisions are made from.
197#[must_use]
198pub fn config() -> Arc<Config> {
199    match source() {
200        Source::Ambient => Config::load_arc(),
201        Source::Pinned(None) => Arc::new(Config::default()),
202        Source::Pinned(Some(home)) => pinned_config(&home),
203    }
204}
205
206fn pinned_config(home: &Path) -> Arc<Config> {
207    static CACHE: Mutex<PinnedCache> = Mutex::new(None);
208    let dir = config_dir_in(home);
209    let global = std::fs::read_to_string(dir.join("config.toml")).ok();
210    // The project is the working directory's checkout: neither
211    // `LEAN_CTX_PROJECT_ROOT` nor a session's root decides it here.
212    let root = project_root_from_cwd();
213    let local = root.as_deref().and_then(|r| {
214        let path = Config::local_path(r);
215        crate::core::pathutil::may_probe_path(&path)
216            .then(|| std::fs::read_to_string(path).ok())
217            .flatten()
218    });
219    let local_hash = local.as_deref().map(crate::core::hasher::hash_str);
220    let trusted = match (root.as_deref(), local_hash.as_deref()) {
221        (Some(r), Some(hash)) => crate::core::workspace_trust::is_pinned_in_store(
222            &dir.join(crate::core::workspace_trust::FILE_NAME),
223            Path::new(r),
224            hash,
225        ),
226        _ => false,
227    };
228    let key = format!(
229        "{}\0{}\0{}\0{}\0{trusted}",
230        dir.display(),
231        global
232            .as_deref()
233            .map(crate::core::hasher::hash_str)
234            .unwrap_or_default(),
235        root.as_deref().unwrap_or_default(),
236        local_hash.as_deref().unwrap_or_default(),
237    );
238    let mut guard = CACHE
239        .lock()
240        .unwrap_or_else(std::sync::PoisonError::into_inner);
241    if let Some((cached_key, cfg)) = guard.as_ref()
242        && *cached_key == key
243    {
244        return Arc::clone(cfg);
245    }
246    // An unparsable file falls back to the defaults, which enforce.
247    let mut cfg = global
248        .as_deref()
249        .and_then(|raw| super::loader::parse_config_with_profile(raw, None).ok())
250        .unwrap_or_default();
251    if let Some(local) = local.as_deref() {
252        super::loader::without_env_profile(|| cfg.merge_local(local, trusted));
253    }
254    let cfg = Arc::new(cfg);
255    *guard = Some((key, Arc::clone(&cfg)));
256    cfg
257}
258
259/// The nearest directory at or above the working directory that holds a
260/// `.git` entry, else the working directory itself.
261fn project_root_from_cwd() -> Option<String> {
262    let cwd = std::env::current_dir().ok()?;
263    let root = cwd
264        .ancestors()
265        .find(|dir| dir.join(".git").exists())
266        .unwrap_or(&cwd);
267    Some(root.to_string_lossy().into_owned())
268}
269
270/// A gate-loosening environment variable, honoured only by the ambient
271/// (test/debug) source. Release builds never read it.
272#[must_use]
273pub fn env(name: &str) -> Option<String> {
274    match source() {
275        Source::Ambient => std::env::var(name).ok(),
276        Source::Pinned(_) => None,
277    }
278}
279
280fn env_flag(name: &str) -> Option<bool> {
281    env(name).map(|raw| {
282        matches!(
283            raw.trim().to_ascii_lowercase().as_str(),
284            "1" | "true" | "yes" | "on"
285        )
286    })
287}
288
289/// `shell_allow_inline_scripts` for the gate.
290#[must_use]
291pub fn allow_inline_scripts() -> bool {
292    env_flag("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS")
293        .unwrap_or_else(|| config().shell_allow_inline_scripts)
294}
295
296/// `shell_allow_writes` for `ctx_shell`.
297#[must_use]
298pub fn allow_writes() -> bool {
299    env_flag("LEAN_CTX_SHELL_ALLOW_WRITES").unwrap_or_else(|| config().shell_allow_writes)
300}
301
302/// `shell_approval` for the gate.
303#[must_use]
304pub fn approval() -> ShellApproval {
305    match source() {
306        Source::Ambient => ShellApproval::effective(&config()),
307        Source::Pinned(_) => config().shell_approval,
308    }
309}
310
311/// `shell_hook_mode` for the hooks (empty when unset).
312#[must_use]
313pub fn shell_hook_mode() -> String {
314    env("LEAN_CTX_SHELL_HOOK_MODE")
315        .filter(|v| !v.trim().is_empty())
316        .or_else(|| config().shell_hook_mode.clone())
317        .unwrap_or_default()
318}
319
320/// `LEAN_CTX_ALLOWLIST_WARN_ONLY`: the test/debug opt-out of the CLI gate.
321#[must_use]
322pub fn cli_warn_only_requested() -> bool {
323    env("LEAN_CTX_ALLOWLIST_WARN_ONLY").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn real_user_home_ignores_home_env() {
332        let _lock = crate::core::data_dir::test_env_lock();
333        let before = real_user_home();
334        let _home = crate::test_env::VarGuard::set("HOME", "/tmp/forged-home");
335        assert_eq!(real_user_home(), before);
336        assert_ne!(
337            real_user_home().as_deref(),
338            Some(Path::new("/tmp/forged-home"))
339        );
340    }
341
342    #[test]
343    fn pinned_source_ignores_env_and_relocated_config() {
344        let _lock = crate::core::data_dir::test_env_lock();
345        let real = tempfile::tempdir().unwrap();
346        let forged = tempfile::tempdir().unwrap();
347        std::fs::create_dir_all(forged.path().join(".config/lean-ctx")).unwrap();
348        std::fs::write(
349            forged.path().join(".config/lean-ctx/config.toml"),
350            "shell_security = \"off\"\nshell_allow_inline_scripts = true\n",
351        )
352        .unwrap();
353        let _pin = pin_for_test(real.path());
354        let _a = crate::test_env::VarGuard::set("LEAN_CTX_SHELL_SECURITY", "off");
355        let _b = crate::test_env::VarGuard::set("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS", "1");
356        let _c = crate::test_env::VarGuard::set("HOME", forged.path());
357        let _d = crate::test_env::VarGuard::set("LEAN_CTX_CONFIG_DIR", forged.path());
358        assert_eq!(env("LEAN_CTX_SHELL_SECURITY"), None);
359        assert!(!allow_inline_scripts());
360        assert_eq!(config().shell_security, None);
361        assert_eq!(
362            config_path(),
363            Some(real.path().join(".config/lean-ctx/config.toml"))
364        );
365    }
366
367    #[test]
368    fn pinned_source_reads_the_real_home_config() {
369        let _lock = crate::core::data_dir::test_env_lock();
370        let real = tempfile::tempdir().unwrap();
371        std::fs::create_dir_all(real.path().join(".config/lean-ctx")).unwrap();
372        std::fs::write(
373            real.path().join(".config/lean-ctx/config.toml"),
374            "shell_security = \"warn\"\n",
375        )
376        .unwrap();
377        let _pin = pin_for_test(real.path());
378        assert_eq!(config().shell_security.as_deref(), Some("warn"));
379    }
380}