lean-ctx 3.9.14

Context Runtime for AI Agents with CCP. 71 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
//! macOS Seatbelt self-sandbox for launchd-owned lean-ctx processes (#356).
//!
//! The daemon, proxy and auto-updater run as LaunchAgents — i.e. under
//! `launchd` (`ppid 1`) with their own TCC identity. Any `stat`/`read_dir`/
//! `realpath` they perform under `~/Documents`, `~/Desktop` or `~/Downloads`
//! pops the macOS privacy prompt *in lean-ctx's own name*, and because every
//! release re-signs the binary (new cdhash) the grant is invalidated on each
//! update, so it re-prompts forever.
//!
//! The opt-out path guards in [`crate::core::pathutil`] avoid those accesses
//! per call site, but that is fragile: one forgotten probe — or a dependency
//! that walks the filesystem — reintroduces the prompt. This module adds a
//! hard, kernel-enforced backstop: the LaunchAgent `ProgramArguments` are
//! wrapped in `sandbox-exec` with a profile that *denies* file access under the
//! three TCC-protected home directories. If any code path touches them anyway,
//! the kernel refuses with `EPERM` silently — the TCC subsystem is never
//! consulted, so no prompt can appear. Everything else is permitted
//! (`allow default`), so the processes keep full functionality.
//!
//! The deny is silent (no `(with send-signal SIGKILL)`): a production process
//! must survive a stray access, losing only that one read. The SIGKILL variant
//! lives solely in `tests/tcc_sandbox.sh`, where dying is how the regression
//! test *detects* an access.

use std::path::Path;
use std::process::Command;

/// Absolute path to the system `sandbox-exec`. Hard-coded rather than resolved
/// via `PATH` so the LaunchAgent invocation never depends on the environment.
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";

/// The macOS "magic" home subdirectories whose mere enumeration trips the TCC
/// privacy prompt (#356).
const TCC_PROTECTED_SUBDIRS: [&str; 3] = ["Documents", "Desktop", "Downloads"];

/// Env sentinel marking that this process is already running under (or is
/// exempt from) the deny-`~/Documents` seatbelt. Set both by the LaunchAgent
/// plist `EnvironmentVariables` (see [`pinned_layout_env_xml`]) and by the
/// self re-exec ([`reexec_under_seatbelt_if_needed`]), so a current-code plist
/// never re-wraps and the re-exec can never loop.
pub const SEATBELT_SENTINEL: &str = "LEAN_CTX_SEATBELT";

/// Build the inline Seatbelt (SBPL) profile that denies all file access under
/// the three TCC-protected home directories while allowing everything else.
///
/// Returns `None` when the home directory cannot be resolved (the caller then
/// falls back to an unwrapped invocation). The home path is canonicalized: the
/// kernel matches sandbox `subpath` filters against the *canonical* path, so a
/// symlinked home would otherwise make the deny rule silently miss. This runs
/// in the CLI/setup context (which holds the TCC grant) and only stats the home
/// directory itself — never a protected subdir — so it cannot trip the prompt.
pub fn tcc_deny_profile() -> Option<String> {
    let home = dirs::home_dir()?;
    let home = std::fs::canonicalize(&home).unwrap_or(home);
    Some(build_profile(&home))
}

/// Assemble the single-line SBPL profile for a concrete home directory.
fn build_profile(home: &Path) -> String {
    let mut subpaths = String::new();
    for sub in TCC_PROTECTED_SUBDIRS {
        let p = home.join(sub);
        subpaths.push_str(&format!(
            " (subpath \"{}\")",
            sbpl_escape(&p.to_string_lossy())
        ));
    }
    // SBPL evaluates last-match-wins, so the deny must follow the allow-default.
    format!("(version 1) (allow default) (deny file-read* file-write*{subpaths})")
}

/// Escape a path for embedding inside an SBPL double-quoted string literal.
/// SBPL uses C-style escaping, so backslashes and double quotes must be escaped.
fn sbpl_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Wrap launchd `ProgramArguments` so the spawned process runs under the
/// deny-`~/Documents` Seatbelt sandbox (#356).
///
/// Returns `[SANDBOX_EXEC, "-p", <profile>, binary, args…]` when `sandbox-exec`
/// is present and accepts the generated profile (smoke-tested against
/// `/usr/bin/true`, so a malformed profile can never wedge the LaunchAgent in a
/// `KeepAlive` crash-loop). Otherwise returns the plain `[binary, args…]`: the
/// binary always launches, with the path guards as the remaining safety layer.
pub fn wrap_launchd_args(binary: &str, args: &[&str]) -> Vec<String> {
    if let Some(profile) = tcc_deny_profile()
        && sandbox_exec_usable(&profile)
    {
        let mut wrapped = vec![
            SANDBOX_EXEC.to_string(),
            "-p".to_string(),
            profile,
            binary.to_string(),
        ];
        wrapped.extend(args.iter().map(|a| (*a).to_string()));
        return wrapped;
    }
    unwrapped_args(binary, args)
}

/// Plain, unwrapped invocation `[binary, args…]` used as the safe fallback.
fn unwrapped_args(binary: &str, args: &[&str]) -> Vec<String> {
    let mut out = vec![binary.to_string()];
    out.extend(args.iter().map(|a| (*a).to_string()));
    out
}

/// Pure decision: should a launchd-standalone process re-exec itself under the
/// seatbelt? Only when it is its own TCC identity *and* not already marked.
/// Factored out so the policy is unit-testable without spawning processes.
#[cfg(target_os = "macos")]
fn should_reexec_under_seatbelt(standalone: bool, sentinel_present: bool) -> bool {
    standalone && !sentinel_present
}

/// Belt-and-suspenders (#356): if this is a launchd-standalone process
/// (daemon / proxy / auto-updater — `ppid 1`, its own TCC identity) that is not
/// already running under the seatbelt, re-exec self wrapped in `sandbox-exec`
/// with the deny-`~/Documents` profile. This closes the one gap the plist
/// rewrite cannot: an install whose LaunchAgent plists predate the seatbelt and
/// is only ever upgraded via `brew upgrade` (which bypasses lean-ctx's updater,
/// so the plists are never regenerated). Without this such a process boots
/// unwrapped and can still trip the TCC prompt.
///
/// No-op unless macOS + standalone + sentinel-absent + `sandbox-exec` accepts
/// the profile. On `exec` failure it returns and the caller continues unwrapped
/// (the [`crate::core::pathutil`] path guards remain the safety net) rather than
/// aborting a `KeepAlive` daemon into a crash loop.
#[cfg(target_os = "macos")]
pub fn reexec_under_seatbelt_if_needed() {
    use std::os::unix::process::CommandExt;

    let sentinel = std::env::var_os(SEATBELT_SENTINEL).is_some();
    let standalone = crate::core::pathutil::process_is_tcc_standalone();
    if !should_reexec_under_seatbelt(standalone, sentinel) {
        return;
    }
    let Some(profile) = tcc_deny_profile() else {
        return;
    };
    if !sandbox_exec_usable(&profile) {
        return;
    }
    let Ok(exe) = std::env::current_exe() else {
        return;
    };
    let args: Vec<String> = std::env::args().skip(1).collect();
    // `exec` replaces the image; it only returns on failure.
    let err = Command::new(SANDBOX_EXEC)
        .arg("-p")
        .arg(&profile)
        .arg(&exe)
        .args(&args)
        .env(SEATBELT_SENTINEL, "1")
        .exec();
    tracing::warn!("#356 seatbelt re-exec failed, continuing unwrapped: {err}");
}

/// `true` if `sandbox-exec` exists and successfully runs a no-op under
/// `profile`. Guards against both a missing binary and an SBPL syntax error,
/// either of which would otherwise turn a `KeepAlive` LaunchAgent into a
/// crash-loop.
fn sandbox_exec_usable(profile: &str) -> bool {
    if !Path::new(SANDBOX_EXEC).exists() {
        return false;
    }
    Command::new(SANDBOX_EXEC)
        .args(["-p", profile, "/usr/bin/true"])
        .output()
        .is_ok_and(|o| o.status.success())
}

/// Build the `EnvironmentVariables` plist block that pins a launchd-spawned
/// lean-ctx process to the directory layout the *installing CLI* resolves (#449).
///
/// A LaunchAgent inherits only launchd's minimal environment (no `HOME`, no XDG
/// vars), so the proxy/daemon would otherwise resolve a *different* config/data
/// dir than the CLI that installed it: it never sees the user's `config.toml`
/// edits (the live-upstream reload reads an empty/foreign config) and derives a
/// mismatched session token. Baking the exact resolved dirs into the plist makes
/// the managed process always agree with the CLI, on every platform layout
/// (legacy `~/.lean-ctx`, mixed, or split XDG).
///
/// Returns the full `<key>EnvironmentVariables</key><dict>…</dict>` block
/// (4-space indented, trailing newline) or an empty string when nothing resolves.
pub fn pinned_layout_env_xml() -> String {
    let mut entries: Vec<(&str, String)> = Vec::new();
    // #356 sentinel: marks the spawned process as managed by current-code
    // (it carries the seatbelt wrapper, or wrapping is impossible on this host).
    // Its presence makes `reexec_under_seatbelt_if_needed` a no-op, so a current
    // plist never double-wraps — only stale, pre-sentinel plists re-exec.
    entries.push((SEATBELT_SENTINEL, "1".to_string()));
    if let Some(home) = dirs::home_dir() {
        entries.push(("HOME", home.display().to_string()));
    }
    for (key, dir) in [
        ("LEAN_CTX_CONFIG_DIR", crate::core::paths::config_dir()),
        ("LEAN_CTX_DATA_DIR", crate::core::paths::data_dir()),
        ("LEAN_CTX_STATE_DIR", crate::core::paths::state_dir()),
        ("LEAN_CTX_CACHE_DIR", crate::core::paths::cache_dir()),
    ] {
        if let Ok(p) = dir {
            entries.push((key, p.display().to_string()));
        }
    }
    if entries.is_empty() {
        return String::new();
    }
    let body = entries
        .iter()
        .map(|(k, v)| {
            format!(
                "        <key>{k}</key>\n        <string>{}</string>",
                xml_escape(v)
            )
        })
        .collect::<Vec<_>>()
        .join("\n");
    format!("    <key>EnvironmentVariables</key>\n    <dict>\n{body}\n    </dict>\n")
}

/// Render a ProgramArguments list as XML-escaped plist `<string>` lines, each
/// prefixed with `indent` and joined by newlines — ready to drop inside the
/// `<array>` body of a LaunchAgent plist.
pub fn program_args_xml(args: &[String], indent: &str) -> String {
    args.iter()
        .map(|a| format!("{indent}<string>{}</string>", xml_escape(a)))
        .collect::<Vec<_>>()
        .join("\n")
}

/// Minimal XML escaping for plist `<string>` bodies: `&`, `<` and `>` must be
/// encoded so the plist stays well-formed. Double quotes are valid in element
/// content and are left as-is (launchd's parser hands them through verbatim).
pub fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn profile_allows_default_and_denies_magic_dirs() {
        let profile = build_profile(Path::new("/Users/dev"));
        assert!(profile.contains("(version 1)"));
        assert!(profile.contains("(allow default)"));
        assert!(profile.contains("(deny file-read* file-write*"));
        assert!(profile.contains("(subpath \"/Users/dev/Documents\")"));
        assert!(profile.contains("(subpath \"/Users/dev/Desktop\")"));
        assert!(profile.contains("(subpath \"/Users/dev/Downloads\")"));
    }

    #[test]
    fn profile_is_single_line_and_deny_follows_allow() {
        // Last-match-wins SBPL: the deny must come after allow-default.
        let profile = build_profile(Path::new("/Users/dev"));
        assert!(!profile.contains('\n'));
        let allow_idx = profile.find("(allow default)").unwrap();
        let deny_idx = profile.find("(deny file-read*").unwrap();
        assert!(allow_idx < deny_idx);
    }

    #[test]
    fn tcc_deny_profile_names_all_three_dirs() {
        if let Some(profile) = tcc_deny_profile() {
            assert!(profile.contains("/Documents\")"));
            assert!(profile.contains("/Desktop\")"));
            assert!(profile.contains("/Downloads\")"));
        }
    }

    #[test]
    fn sbpl_escape_handles_quotes_and_backslashes() {
        assert_eq!(sbpl_escape(r#"/Users/a"b"#), r#"/Users/a\"b"#);
        assert_eq!(sbpl_escape(r"/Users/a\b"), r"/Users/a\\b");
        assert_eq!(sbpl_escape("/Users/normal"), "/Users/normal");
    }

    #[test]
    fn unwrapped_args_prepend_binary() {
        let got = unwrapped_args("/bin/lean-ctx", &["serve", "--_foreground-daemon"]);
        assert_eq!(got, vec!["/bin/lean-ctx", "serve", "--_foreground-daemon"]);
    }

    #[test]
    fn program_args_xml_escapes_and_indents() {
        let args = vec![
            "/usr/bin/sandbox-exec".to_string(),
            "-p".to_string(),
            "(deny a&b<c>)".to_string(),
        ];
        let xml = program_args_xml(&args, "        ");
        assert!(xml.contains("        <string>/usr/bin/sandbox-exec</string>"));
        assert!(xml.contains("&amp;"));
        assert!(xml.contains("&lt;"));
        assert!(xml.contains("&gt;"));
        // No raw ampersand may survive — that would break the plist XML.
        assert!(!xml.contains("a&b"));
    }

    #[test]
    fn pinned_layout_env_pins_all_categories() {
        let xml = pinned_layout_env_xml();
        assert!(xml.starts_with("    <key>EnvironmentVariables</key>"));
        assert!(xml.contains("<dict>"));
        assert!(xml.trim_end().ends_with("</dict>"));
        for key in [
            "LEAN_CTX_CONFIG_DIR",
            "LEAN_CTX_DATA_DIR",
            "LEAN_CTX_STATE_DIR",
            "LEAN_CTX_CACHE_DIR",
        ] {
            assert!(xml.contains(&format!("<key>{key}</key>")), "missing {key}");
        }
    }

    #[test]
    fn pinned_layout_env_carries_seatbelt_sentinel() {
        // The plist must mark current-code processes so the boot-time re-exec
        // guard (#356) treats them as already-wrapped and never double-wraps.
        let xml = pinned_layout_env_xml();
        assert!(xml.contains(&format!("<key>{SEATBELT_SENTINEL}</key>")));
    }

    #[test]
    #[cfg(target_os = "macos")]
    fn reexec_policy_only_fires_for_unmarked_standalone() {
        // Standalone + no sentinel = stale plist → must re-exec under seatbelt.
        assert!(should_reexec_under_seatbelt(true, false));
        // Already marked (current plist or prior re-exec) → never loop.
        assert!(!should_reexec_under_seatbelt(true, true));
        // Terminal/editor child (host TCC grant) → never re-exec.
        assert!(!should_reexec_under_seatbelt(false, false));
        assert!(!should_reexec_under_seatbelt(false, true));
    }

    #[test]
    fn wrap_includes_sandbox_exec_and_binary_when_usable() {
        // Assert the real wrapping only when sandbox-exec works on this host
        // (CI macOS runners have it); otherwise verify the safe fallback.
        let wrapped = wrap_launchd_args("/bin/lean-ctx", &["proxy", "start"]);
        if wrapped.first().map(String::as_str) == Some(SANDBOX_EXEC) {
            assert_eq!(wrapped[1], "-p");
            assert!(wrapped[2].contains("(deny file-read*"));
            assert_eq!(wrapped[3], "/bin/lean-ctx");
            assert_eq!(&wrapped[4..], &["proxy", "start"]);
        } else {
            assert_eq!(wrapped, vec!["/bin/lean-ctx", "proxy", "start"]);
        }
    }
}