Skip to main content

lean_ctx/core/
protected_store.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Kernel-enforced control-file immutability for the managed macOS MCP child.
3//! Named Unix sockets are restricted too; TCP and Mach are not contained here.
4
5use crate::core::policy::runtime::{REQUIRED_POLICY_DIGEST_ENV, REQUIRED_POLICY_ROOT_ENV};
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Component, Path, PathBuf};
8use std::process::{Command, Stdio};
9
10pub(crate) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
11/// Fixed opening of every profile this module compiles.
12const PROFILE_HEADER: &str = "(version 1)\n(allow default)\n";
13const MAX_ENTRIES: usize = 4096;
14const MAX_PROFILE_BYTES: usize = 128 * 1024;
15
16/// Compiled kernel rules for every model-directed subprocess of the protected
17/// MCP server, pinned by the launcher into the server's environment. Read from
18/// the process environment only — a tool call must never be able to select,
19/// weaken or drop them.
20pub const CHILD_PROFILE_ENV: &str = "LEAN_CTX_PROTECTED_CHILD_PROFILE";
21
22/// Model-directed processes cannot read or modify runtime state. Only build
23/// work and its coordination directory are exceptions; new stores and rotated
24/// files inherit the default denial. Engine persistence runs outside this guard.
25const STORE_WORK_MEMBERS: &[&str] = &["build-cache", "resources"];
26
27/// Resolve through the existing taxonomy before pinning it in the child env.
28/// Do not create directories, migrate providers or read credential contents.
29pub fn bind_authority(
30    project: &Path,
31    home: &Path,
32    env: &mut BTreeMap<String, String>,
33) -> Result<Vec<PathBuf>, String> {
34    use crate::core::{data_dir, paths, policy::runtime};
35    let config = paths::config_dir_read_only()?;
36    let data = data_dir::resolve_data_dir()?;
37    let bindings = [
38        ("LEAN_CTX_CONFIG_DIR", config.clone()),
39        ("LEAN_CTX_DATA_DIR", data.clone()),
40        ("LEAN_CTX_STATE_DIR", paths::state_dir_read_only()?),
41        ("LEAN_CTX_CACHE_DIR", paths::cache_dir_read_only()?),
42    ];
43    for (key, value) in &bindings {
44        env.insert((*key).into(), checked_path(value)?);
45    }
46
47    let mut controls = runtime::protected_authority_paths(project)?;
48    let xdg_config =
49        paths::xdg_config_lean_ctx_dir().ok_or("XDG configuration path unavailable")?;
50    controls.push(xdg_config.join(crate::core::layout_pin::LAYOUT_FILE));
51    // Protect dormant authorities too: a session must not plant a different
52    // configuration or layout marker that the next launch would silently adopt.
53    for candidate in [
54        home.join(".lean-ctx"),
55        xdg_config,
56        paths::data_split_target()?,
57    ] {
58        for member in [
59            "config.toml",
60            "workspace-trust.toml",
61            "org-policy.signed.json",
62            "org-trust.toml",
63            "roles",
64            "profiles",
65            "personas",
66            "multi-repo.toml",
67        ] {
68            controls.push(candidate.join(member));
69        }
70        if resolve_missing(&candidate)? != resolve_missing(&data)? {
71            for marker in data_dir::DATA_MARKERS {
72                let path = candidate.join(marker);
73                let resolved_marker = resolve_missing(&path)?;
74                for (_, dir) in &bindings[1..] {
75                    if resolve_missing(dir)?.starts_with(&resolved_marker) {
76                        return Err(format!(
77                            "runtime directory {} overlaps dormant layout marker {}; select a stable layout before a protected launch",
78                            dir.display(),
79                            path.display()
80                        ));
81                    }
82                }
83                controls.push(path);
84            }
85        }
86    }
87    for member in ["personas", "multi-repo.toml"] {
88        controls.push(config.join(member));
89    }
90    for base in [
91        config.clone(),
92        home.join(".lean-ctx"),
93        project.join(".lean-ctx"),
94    ] {
95        controls.push(base.join("providers.toml"));
96        controls.push(base.join("providers"));
97    }
98    // Config-member migration is an operator action: the protected child must
99    // neither relocate legacy authority nor silently omit it after a deny.
100    if let Some(legacy) = dirs::config_dir().map(|path| path.join("lean-ctx")) {
101        for member in [
102            "config.toml",
103            "providers.toml",
104            "providers",
105            "personas",
106            "multi-repo.toml",
107        ] {
108            let old = legacy.join(member);
109            let current = config.join(member);
110            if old != current && old.exists() && !current.exists() {
111                return Err(format!(
112                    "legacy configuration {} must be migrated before a protected launch",
113                    old.display()
114                ));
115            }
116            controls.push(old);
117        }
118    }
119    for member in ["roles", "profiles"] {
120        controls.push(data.join(member));
121        controls.push(project.join(".lean-ctx").join(member));
122    }
123    controls.push(project.join(".lean-ctx/policies.json"));
124    controls.push(home.join(".claude/settings.json"));
125    controls.push(project.join(".claude/settings.local.json"));
126    controls.push(home.join(".codex/config.toml"));
127    Ok(controls)
128}
129
130/// Dashboard credentials are operator authority, not provider credentials.
131/// Include dormant layouts so switching or migrating a layout cannot expose an
132/// old token. Resolve paths only; never read, rotate or migrate the credential.
133pub fn operator_credentials(home: &Path) -> Result<Vec<PathBuf>, String> {
134    use crate::core::{data_dir, paths};
135    let mut directories = vec![
136        paths::state_dir_read_only()?,
137        paths::state_split_target()?,
138        paths::config_dir_read_only()?,
139        data_dir::resolve_data_dir()?,
140        home.join(".lean-ctx"),
141        paths::xdg_config_lean_ctx_dir().ok_or("XDG configuration path unavailable")?,
142    ];
143    if let Some(legacy) = dirs::config_dir() {
144        directories.push(legacy.join("lean-ctx"));
145    }
146    Ok(directories
147        .into_iter()
148        .map(|directory| directory.join("dashboard.token"))
149        .collect())
150}
151
152pub fn credential_read_paths(paths: &[PathBuf]) -> Result<BTreeSet<String>, String> {
153    let mut guard = Guard::default();
154    for path in paths {
155        guard.protect(path, true)?;
156    }
157    Ok(guard.reads)
158}
159
160/// Compile the whole-MCP control-file profile from `(path, deny_read)` entries,
161/// protected in the given order. Every entry denies writes; `deny_read` also
162/// denies reads (credentials and session homes).
163pub fn control_profile<P: AsRef<Path>>(
164    entries: impl IntoIterator<Item = (P, bool)>,
165) -> Result<String, String> {
166    let mut guard = Guard::default();
167    for (path, deny_read) in entries {
168        guard.protect(path.as_ref(), deny_read)?;
169    }
170    guard.render()
171}
172
173#[derive(Default)]
174struct Guard {
175    writes: BTreeSet<String>,
176    reads: BTreeSet<String>,
177    work_paths: BTreeSet<String>,
178    ancestors: BTreeSet<String>,
179    visited: BTreeSet<(PathBuf, bool)>,
180    entries: usize,
181}
182
183impl Guard {
184    fn protect(&mut self, path: &Path, deny_read: bool) -> Result<(), String> {
185        self.entries += 1;
186        if self.entries > MAX_ENTRIES {
187            return Err("too many control-file entries for a bounded protected launch".into());
188        }
189        self.add_path(path, deny_read)?;
190        let resolved = resolve_missing(path)?;
191        self.add_path(&resolved, deny_read)?;
192        if !self.visited.insert((resolved.clone(), deny_read)) {
193            return Ok(());
194        }
195        let metadata = match std::fs::symlink_metadata(&resolved) {
196            Ok(metadata) => metadata,
197            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
198            Err(error) => return Err(format!("cannot inspect {}: {error}", resolved.display())),
199        };
200        if metadata.is_file() {
201            #[cfg(unix)]
202            {
203                use std::os::unix::fs::MetadataExt;
204                if metadata.nlink() != 1 {
205                    return Err(format!(
206                        "hardlinked control file {} cannot be protected by path rules; use a regular file or a symlink to one before restarting",
207                        resolved.display()
208                    ));
209                }
210            }
211        } else if metadata.is_dir() {
212            for entry in std::fs::read_dir(&resolved)
213                .map_err(|error| format!("cannot inspect {}: {error}", resolved.display()))?
214            {
215                let entry =
216                    entry.map_err(|error| format!("cannot inspect control entry: {error}"))?;
217                self.protect(&entry.path(), deny_read)?;
218            }
219        } else {
220            return Err(format!(
221                "control path {} is not a regular file or directory",
222                resolved.display()
223            ));
224        }
225        Ok(())
226    }
227
228    /// Deny an entire store subtree, reads included, without enumerating it.
229    /// `subpath` already covers every descendant, and a store directory is not a
230    /// control *file* whose link count has to be inspected — walking `vectors/`
231    /// would only spend the bounded entry budget on rules `subpath` implies.
232    /// Both the lexical and the resolved form are emitted, so replacing an
233    /// ancestor cannot redirect an access around the deny.
234    fn deny_subtree(&mut self, path: &Path) -> Result<(), String> {
235        self.entries += 1;
236        if self.entries > MAX_ENTRIES {
237            return Err("too many store entries for a bounded protected launch".into());
238        }
239        self.add_path(path, true)?;
240        self.add_path(&resolve_missing(path)?, true)
241    }
242
243    fn add_path(&mut self, path: &Path, deny_read: bool) -> Result<(), String> {
244        let text = checked_path(path)?;
245        self.writes.insert(text.clone());
246        if deny_read {
247            self.reads.insert(text);
248        }
249        for ancestor in path.ancestors().skip(1) {
250            // Protect lexical aliases too: replacing an ancestor symlink must
251            // not redirect a later load outside the resolved deny set.
252            self.ancestors.insert(checked_path(ancestor)?);
253            self.ancestors
254                .insert(checked_path(&resolve_missing(ancestor)?)?);
255        }
256        Ok(())
257    }
258
259    fn render(&self) -> Result<String, String> {
260        let mut result = String::from(PROFILE_HEADER);
261        // Credential-bearing MCP/wrapper memory is not a child-process API.
262        result.push_str("(deny mach-task-read)\n(deny mach-task-name)\n");
263        // Descendants must not delegate control-file writes to an outside
264        // daemon, or accept a helper on a named Unix listener. The private
265        // Intelligence peer uses an inherited socketpair, never a /tmp exception.
266        // OS DNS remains necessary for authorized HTTPS providers.
267        result.push_str(
268            "(deny network-outbound (regex #\"^/\"))\n\
269             (allow network-outbound (literal \"/private/var/run/mDNSResponder\"))\n\
270             (deny network-bind (regex #\"^/\"))\n",
271        );
272        for path in &self.writes {
273            result.push_str(&deny_write_rule(path));
274        }
275        for path in &self.reads {
276            result.push_str(&deny_read_rule(path));
277        }
278        for path in &self.work_paths {
279            result.push_str(&format!(
280                "(allow file-read* file-write* (subpath {}))\n",
281                quote(path)
282            ));
283        }
284        for path in &self.ancestors {
285            result.push_str(&format!(
286                "(deny file-write-unlink (literal {}))\n",
287                quote(path)
288            ));
289            // An absent ancestor may become a normal directory, but never a
290            // symlink to an unprotected tree (including rename into its place).
291            result.push_str(&format!(
292                "(deny file-write-create (require-all (literal {}) (vnode-type SYMLINK)))\n",
293                quote(path)
294            ));
295        }
296        if result.len() > MAX_PROFILE_BYTES {
297            return Err(
298                "control-file kernel profile exceeds the protected launch size limit".into(),
299            );
300        }
301        Ok(result)
302    }
303}
304
305fn checked_path(path: &Path) -> Result<String, String> {
306    let text = path.to_str().ok_or("control path is not Unicode")?;
307    if !path.is_absolute()
308        || text.chars().any(char::is_control)
309        || path
310            .components()
311            .any(|part| matches!(part, Component::ParentDir | Component::CurDir))
312    {
313        return Err(
314            "control paths must be absolute, without dot components or control characters".into(),
315        );
316    }
317    Ok(text.into())
318}
319
320/// Strict canonicalization of the nearest existing ancestor. Dangling links,
321/// permission failures and non-directory ancestors must never become absence.
322fn resolve_missing(path: &Path) -> Result<PathBuf, String> {
323    checked_path(path)?;
324    match std::fs::symlink_metadata(path) {
325        Ok(_) => path
326            .canonicalize()
327            .map_err(|error| format!("cannot resolve {}: {error}", path.display())),
328        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
329            let parent = path.parent().ok_or("control path has no parent")?;
330            let name = path.file_name().ok_or("control path has no file name")?;
331            Ok(resolve_missing(parent)?.join(name))
332        }
333        Err(error) => Err(format!("cannot inspect {}: {error}", path.display())),
334    }
335}
336
337fn quote(value: &str) -> String {
338    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
339}
340
341fn deny_write_rule(path: &str) -> String {
342    format!("(deny file-write* (subpath {}))\n", quote(path))
343}
344
345fn deny_read_rule(path: &str) -> String {
346    format!("(deny file-read* (subpath {}))\n", quote(path))
347}
348
349/// Every layout that could carry this project's durable store: the live one plus
350/// the dormant candidates `bind_authority` already refuses to let a session
351/// re-point. Denying only the live directory would leave a session free to read
352/// or seed a store that the next launch adopts.
353fn store_roots(home: &Path) -> Result<Vec<PathBuf>, String> {
354    use crate::core::{data_dir, paths};
355    let mut roots = vec![
356        data_dir::resolve_data_dir()?,
357        home.join(".lean-ctx"),
358        paths::xdg_config_lean_ctx_dir().ok_or("XDG configuration path unavailable")?,
359        paths::xdg_base("XDG_DATA_HOME", ".local/share")?.join("lean-ctx"),
360        paths::state_dir_read_only()?,
361        paths::xdg_base("XDG_STATE_HOME", ".local/state")?.join("lean-ctx"),
362        paths::cache_dir_read_only()?,
363        paths::xdg_base("XDG_CACHE_HOME", ".cache")?.join("lean-ctx"),
364        dirs::cache_dir()
365            .unwrap_or_else(|| PathBuf::from("/tmp"))
366            .join("lean-ctx"),
367    ];
368    roots.sort();
369    roots.dedup();
370    Ok(roots)
371}
372
373/// Kernel rules for a model-directed subprocess of the protected MCP server.
374/// Additive on top of the whole-MCP profile such a subprocess already inherits:
375/// a nested Seatbelt profile can only remove rights, so `(allow default)` here
376/// restores nothing the launcher denied. The Engine's own persistence is
377/// untouched — it never runs inside this profile.
378///
379/// Compiled before the launch, so a session never starts carrying rules the
380/// kernel would reject.
381pub fn child_profile(home: &Path) -> Result<String, String> {
382    let profile = store_guard(home)?.render()?;
383    validate(&profile)?;
384    Ok(profile)
385}
386
387fn store_guard(home: &Path) -> Result<Guard, String> {
388    let mut guard = Guard::default();
389    deny_store(&mut guard, &store_roots(home)?)?;
390    Ok(guard)
391}
392
393/// The same boundary for model-directed in-process file tools. Reject ancestor
394/// directories too: a recursive search must not enumerate a store through an
395/// otherwise allowed parent. Internal persistence does not use this interface.
396pub fn enforce_protected_store_path(candidate: &Path) -> Result<(), String> {
397    let Some((_, guard)) = pinned_child_boundary()? else {
398        return Ok(());
399    };
400    let resolved = resolve_missing(candidate)?;
401    for path in [candidate, resolved.as_path()] {
402        if guard
403            .reads
404            .iter()
405            .any(|denied| path.starts_with(denied) || Path::new(denied).starts_with(path))
406            && !guard
407                .work_paths
408                .iter()
409                .any(|allowed| path.starts_with(allowed))
410        {
411            return Err("use an authorized context tool to access runtime context".into());
412        }
413    }
414    Ok(())
415}
416
417fn deny_store(guard: &mut Guard, roots: &[PathBuf]) -> Result<(), String> {
418    for root in roots {
419        guard.deny_subtree(root)?;
420    }
421    for root in roots {
422        let resolved_root = resolve_missing(root)?;
423        for member in STORE_WORK_MEMBERS {
424            let path = root.join(member);
425            let resolved = resolve_missing(&path)?;
426            if resolved != resolved_root.join(member) {
427                return Err(
428                    "runtime build directories must not redirect the store boundary".into(),
429                );
430            }
431            // Never reopen another runtime root nested inside a build exception.
432            if guard.reads.iter().any(|denied| {
433                Path::new(denied).starts_with(&path) || Path::new(denied).starts_with(&resolved)
434            }) {
435                return Err("runtime store overlaps a build directory".into());
436            }
437            guard.work_paths.insert(checked_path(&path)?);
438            guard.work_paths.insert(checked_path(&resolved)?);
439            // An attacker cannot replace the exception itself with a symlink.
440            for work in [&path, &resolved] {
441                guard.ancestors.insert(checked_path(work)?);
442            }
443        }
444    }
445    Ok(())
446}
447
448/// Program and leading arguments that run a model-directed command under the
449/// pinned child rules, or `None` outside a protected session.
450///
451/// Fails closed: a process carrying the protected launch authority but no usable
452/// rules runs nothing at all. The rules are never taken from a caller — only
453/// from the environment the launcher pinned.
454pub fn protected_child_prefix() -> Result<Option<Vec<String>>, String> {
455    let Some(profile) = pinned_child_profile()? else {
456        return Ok(None);
457    };
458    Ok(Some(vec![
459        SANDBOX_EXEC.to_string(),
460        "-p".to_string(),
461        profile,
462    ]))
463}
464
465/// Inspect pinned denials in boundary validation tests.
466#[cfg(all(test, target_os = "macos"))]
467pub(crate) fn protected_store_denies() -> Result<Option<String>, String> {
468    Ok(pinned_child_profile()?.map(|profile| {
469        let mut denials = String::new();
470        for line in profile.lines().filter(|line| line.starts_with("(deny ")) {
471            denials.push_str(line);
472            denials.push('\n');
473        }
474        denials
475    }))
476}
477
478/// Validated pinned rules, or `None` when this process was not started by the
479/// protected launcher. Every inconsistency between the pinned authority, the
480/// platform and the live layout is an error, never a downgrade to no rules.
481fn pinned_child_profile() -> Result<Option<String>, String> {
482    Ok(pinned_child_boundary()?.map(|(profile, _)| profile))
483}
484
485fn pinned_child_boundary() -> Result<Option<(String, Guard)>, String> {
486    let authority = [REQUIRED_POLICY_ROOT_ENV, REQUIRED_POLICY_DIGEST_ENV].map(|key| {
487        std::env::var(key)
488            .ok()
489            .filter(|value| !value.trim().is_empty())
490    });
491    let pinned = std::env::var(CHILD_PROFILE_ENV)
492        .ok()
493        .filter(|value| !value.trim().is_empty());
494    if authority.iter().all(Option::is_none) {
495        // Community. Rules without a protected authority are not a session this
496        // process may interpret, and ignoring them would hide the inconsistency.
497        if pinned.is_some() {
498            return Err(format!(
499                "{CHILD_PROFILE_ENV} is set without the protected launch authority; start the session with `lean-ctx codex-protected`"
500            ));
501        }
502        return Ok(None);
503    }
504    if authority.iter().any(Option::is_none) {
505        return Err("incomplete protected launch authority; restart codex-protected".into());
506    }
507    if !cfg!(target_os = "macos") {
508        return Err(
509            "protected sessions isolate the context store from model-directed subprocesses on \
510             macOS only; this platform has no qualified per-child facility"
511                .into(),
512        );
513    }
514    let profile = pinned.ok_or_else(|| {
515        format!(
516            "protected session without pinned subprocess rules ({CHILD_PROFILE_ENV} is unset); restart codex-protected"
517        )
518    })?;
519    // Exact deterministic regeneration rejects appended allows, commented-out
520    // denials and changed live/dormant paths; substring containment cannot do so.
521    let home = dirs::home_dir().ok_or("configuration home unavailable")?;
522    let guard = store_guard(&home)?;
523    if profile.len() > MAX_PROFILE_BYTES || profile != guard.render()? {
524        return Err(
525            "pinned subprocess rules differ from the runtime store boundary; restart codex-protected \
526             after the layout change"
527                .into(),
528        );
529    }
530    Ok(Some((profile, guard)))
531}
532
533/// Pin a synthetic protected session around `store_root` so tests of the shared
534/// exec seams can exercise the real pin instead of a stand-in. The caller must
535/// hold `data_dir::test_env_lock` and point the live data dir at `store_root`.
536/// The returned guard unpins on drop, so a failing assertion cannot leave the
537/// rest of the suite running as a protected session.
538#[cfg(any(test, feature = "test-support"))]
539pub fn pin_synthetic_session(store_root: &Path) -> Result<SyntheticSession, String> {
540    if crate::core::data_dir::resolve_data_dir()? != store_root {
541        return Err("synthetic store is not the isolated live data directory".into());
542    }
543    let home = dirs::home_dir().ok_or("configuration home unavailable")?;
544    let guard = store_guard(&home)?;
545    crate::test_env::set_var(REQUIRED_POLICY_ROOT_ENV, "/synthetic/project");
546    crate::test_env::set_var(REQUIRED_POLICY_DIGEST_ENV, "synthetic-digest");
547    crate::test_env::set_var(CHILD_PROFILE_ENV, guard.render()?);
548    Ok(SyntheticSession)
549}
550
551#[cfg(any(test, feature = "test-support"))]
552pub struct SyntheticSession;
553
554#[cfg(any(test, feature = "test-support"))]
555impl Drop for SyntheticSession {
556    fn drop(&mut self) {
557        unpin_synthetic_session();
558    }
559}
560
561#[cfg(any(test, feature = "test-support"))]
562pub fn unpin_synthetic_session() {
563    for key in [
564        REQUIRED_POLICY_ROOT_ENV,
565        REQUIRED_POLICY_DIGEST_ENV,
566        CHILD_PROFILE_ENV,
567    ] {
568        crate::test_env::remove_var(key);
569    }
570}
571
572/// Compile the actual profile before launching Codex. No unsandboxed fallback.
573pub fn validate(profile: &str) -> Result<(), String> {
574    let status = Command::new(SANDBOX_EXEC)
575        .args(["-p", profile, "/usr/bin/true"])
576        .env_clear()
577        .stdin(Stdio::null())
578        .stdout(Stdio::null())
579        .stderr(Stdio::null())
580        .status()
581        .map_err(|error| format!("protected MCP kernel sandbox unavailable: {error}"))?;
582    if !status.success() {
583        return Err("protected MCP kernel profile was refused; no session was started".into());
584    }
585    Ok(())
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn absent_paths_and_quoted_paths_are_deterministic() {
594        let temp = tempfile::tempdir().unwrap();
595        let path = temp.path().join("ab/c/missing/config.toml");
596        let mut guard = Guard::default();
597        guard.protect(&path, false).unwrap();
598        let profile = guard.render().unwrap();
599        assert!(profile.contains("vnode-type SYMLINK"));
600        assert_eq!(profile, guard.render().unwrap());
601        assert!(!path.exists());
602
603        // Exercise sandbox-string escaping without asking the host filesystem
604        // to represent a quote, which Windows forbids in a path component.
605        let mut quoted = Guard::default();
606        quoted.writes.insert(r#"C:\synthetic\a"b\c"#.to_string());
607        assert!(quoted.render().unwrap().contains("a\\\"b\\\\c"));
608    }
609
610    #[test]
611    fn ambiguous_paths_and_nonregular_files_are_refused() {
612        assert!(checked_path(Path::new("relative/config")).is_err());
613        assert!(checked_path(Path::new("/a/../config")).is_err());
614        assert!(checked_path(Path::new("/a\nconfig")).is_err());
615        #[cfg(unix)]
616        {
617            let temp = tempfile::tempdir().unwrap();
618            let socket = temp.path().join("control.sock");
619            let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
620            assert!(Guard::default().protect(&socket, false).is_err());
621        }
622    }
623
624    #[cfg(unix)]
625    #[test]
626    fn symlink_targets_are_guarded_and_preexisting_hardlinks_refused() {
627        let temp = tempfile::tempdir().unwrap();
628        let target = temp.path().join("actual.toml");
629        std::fs::write(&target, "strict").unwrap();
630        let link = temp.path().join("control.toml");
631        std::os::unix::fs::symlink(&target, &link).unwrap();
632        let mut guard = Guard::default();
633        guard.protect(&link, false).unwrap();
634        assert!(
635            guard
636                .writes
637                .contains(target.canonicalize().unwrap().to_str().unwrap())
638        );
639        let alias = temp.path().join("alias");
640        std::fs::hard_link(&target, &alias).unwrap();
641        assert!(
642            Guard::default()
643                .protect(&link, false)
644                .unwrap_err()
645                .contains("hardlinked")
646        );
647        std::fs::remove_file(&alias).unwrap();
648        std::fs::remove_file(&target).unwrap();
649        assert!(Guard::default().protect(&link, false).is_err());
650    }
651
652    #[cfg(target_os = "macos")]
653    #[test]
654    fn real_kernel_denies_descendant_and_missing_parent_bypasses() {
655        let temp = tempfile::tempdir().unwrap();
656        let root = temp.path().canonicalize().unwrap();
657        let control = root.join("future/config.toml");
658        let mut guard = Guard::default();
659        guard.protect(&control, false).unwrap();
660        let profile = guard.render().unwrap();
661        validate(&profile).unwrap();
662        for (command, success) in [
663            ("mkdir outside && ln -s outside future", false),
664            ("ln -s outside swap && mv swap future", false),
665            ("mkdir future && printf state > future/state", true),
666            ("/bin/sh -c 'printf weak > future/config.toml'", false),
667            ("printf code > main.rs && ln -s outside ordinary", true),
668        ] {
669            let status = Command::new(SANDBOX_EXEC)
670                .args(["-p", &profile, "/bin/sh", "-c", command])
671                .current_dir(&root)
672                .stdout(Stdio::null())
673                .stderr(Stdio::null())
674                .status()
675                .unwrap();
676            assert_eq!(status.success(), success, "{command}");
677        }
678        assert!(!control.exists());
679        assert_eq!(
680            std::fs::read_to_string(root.join("future/state")).unwrap(),
681            "state"
682        );
683        assert!(validate("this is not a kernel profile").is_err());
684    }
685
686    #[cfg(target_os = "macos")]
687    #[test]
688    fn real_kernel_denies_operator_token_access_but_preserves_runtime_state() {
689        let temp = tempfile::tempdir().unwrap();
690        let root = temp.path().canonicalize().unwrap();
691        let token = root.join("dashboard.token");
692        std::fs::write(&token, "synthetic-operator-credential").unwrap();
693        let alias = root.join("token-alias");
694        std::os::unix::fs::symlink(&token, &alias).unwrap();
695        let future = root.join("future/dashboard.token");
696        let mut guard = Guard::default();
697        guard.protect(&alias, true).unwrap();
698        guard.protect(&future, true).unwrap();
699        let profile = guard.render().unwrap();
700        validate(&profile).unwrap();
701        for (command, success) in [
702            ("cat dashboard.token", false),
703            ("cat token-alias", false),
704            ("cp dashboard.token copied-token", false),
705            ("ln dashboard.token hardlink-token", false),
706            ("printf weak > dashboard.token", false),
707            (
708                "mkdir future && printf planted > future/dashboard.token",
709                false,
710            ),
711            ("printf stats > stats.json && cat stats.json", true),
712        ] {
713            let status = Command::new(SANDBOX_EXEC)
714                .args(["-p", &profile, "/bin/sh", "-c", command])
715                .current_dir(&root)
716                .stdout(Stdio::null())
717                .stderr(Stdio::null())
718                .status()
719                .unwrap();
720            assert_eq!(status.success(), success, "{command}");
721        }
722        // An outside operator may create or rotate the credential after launch;
723        // the already-compiled profile must still refuse it.
724        std::fs::write(&future, "rotated-synthetic-credential").unwrap();
725        assert!(
726            !Command::new(SANDBOX_EXEC)
727                .args(["-p", &profile, "/bin/cat"])
728                .arg(&future)
729                .stdout(Stdio::null())
730                .stderr(Stdio::null())
731                .status()
732                .unwrap()
733                .success()
734        );
735    }
736
737    #[cfg(target_os = "macos")]
738    #[test]
739    fn real_kernel_denies_named_ipc_but_preserves_anonymous_channels() {
740        let temp = tempfile::Builder::new()
741            .prefix("lci-")
742            .tempdir_in("/tmp")
743            .unwrap();
744        let root = temp.path().canonicalize().unwrap();
745        let _listener = std::os::unix::net::UnixListener::bind(root.join("helper.sock")).unwrap();
746        let profile = Guard::default().render().unwrap();
747        let module = module_path!().split_once("::").unwrap().1;
748        let probe = format!("{module}::ipc_probe_child");
749        for mode in [
750            "connect",
751            "connect-relative",
752            "bind",
753            "bind-relative",
754            "pair",
755        ] {
756            let result = Command::new(SANDBOX_EXEC)
757                .args(["-p", &profile])
758                .arg(std::env::current_exe().unwrap())
759                .args(["--exact", &probe])
760                .env("LEANCTX_TEST_IPC_PROBE", mode)
761                .current_dir(&root)
762                .output()
763                .unwrap();
764            assert_eq!(
765                result.status.success(),
766                mode == "pair",
767                "{mode}: {}",
768                String::from_utf8_lossy(&result.stderr)
769            );
770        }
771    }
772
773    #[cfg(target_os = "macos")]
774    #[test]
775    fn ipc_probe_child() {
776        use std::io::{Read, Write};
777        use std::os::unix::net::{UnixListener, UnixStream};
778        let Ok(mode) = std::env::var("LEANCTX_TEST_IPC_PROBE") else {
779            return;
780        };
781        let root = std::env::current_dir().unwrap();
782        match mode.as_str() {
783            "connect" => {
784                UnixStream::connect(root.join("helper.sock")).unwrap();
785            }
786            "connect-relative" => {
787                UnixStream::connect("helper.sock").unwrap();
788            }
789            "bind" => {
790                UnixListener::bind(root.join("child.sock")).unwrap();
791            }
792            "bind-relative" => {
793                UnixListener::bind("child.sock").unwrap();
794            }
795            "pair" => {
796                let (mut a, mut b) = UnixStream::pair().unwrap();
797                a.write_all(b"bounded").unwrap();
798                let mut bytes = [0; 7];
799                b.read_exact(&mut bytes).unwrap();
800                assert_eq!(&bytes, b"bounded");
801            }
802            _ => unreachable!(),
803        }
804    }
805
806    /// Store rules for explicit synthetic layout roots. Keeps every assertion
807    /// below independent of the developer's installed layout.
808    fn synthetic_store_profile(roots: &[PathBuf]) -> String {
809        let mut guard = Guard::default();
810        deny_store(&mut guard, roots).unwrap();
811        guard.render().unwrap()
812    }
813
814    #[test]
815    fn child_rules_cover_every_store_member_and_leave_build_paths_writable() {
816        let temp = tempfile::tempdir().unwrap();
817        let base = temp.path().canonicalize().unwrap();
818        let live = base.join("live");
819        let dormant = base.join("dormant");
820        std::fs::create_dir_all(live.join("knowledge")).unwrap();
821        std::fs::create_dir_all(live.join("build-cache/cargo-target")).unwrap();
822        let profile = synthetic_store_profile(&[live.clone(), dormant.clone()]);
823
824        assert!(profile.starts_with(PROFILE_HEADER));
825        // A dormant layout is covered too: a session must not read or seed a
826        // store that the next launch would adopt.
827        for root in [&live, &dormant] {
828            let text = root.to_str().unwrap();
829            assert!(profile.contains(&deny_read_rule(text)), "read {text}");
830            assert!(profile.contains(&deny_write_rule(text)), "write {text}");
831        }
832        // Store isolation, not a read-only data directory: the shared build cache
833        // and the machine-wide build lease stay usable inside the same tree.
834        for member in STORE_WORK_MEMBERS {
835            assert!(
836                profile.contains(&format!(
837                    "(allow file-read* file-write* (subpath {}))",
838                    quote(live.join(member).to_str().unwrap())
839                )),
840                "{member} must stay writable"
841            );
842        }
843        assert_eq!(profile, synthetic_store_profile(&[live, dormant]));
844    }
845
846    #[test]
847    fn pinning_resolved_categories_preserves_legacy_store_rules() {
848        let _isolation = crate::core::data_dir::isolated_data_dir();
849        for key in ["LEAN_CTX_STATE_DIR", "LEAN_CTX_CACHE_DIR"] {
850            crate::test_env::remove_var(key);
851        }
852        let home = dirs::home_dir().unwrap();
853        let before = store_guard(&home).unwrap().render().unwrap();
854        // A custom/legacy data directory collapses categories. The launcher
855        // then pins these resolved paths, which must not remove dormant XDG
856        // roots from the child's independently regenerated boundary.
857        let state = crate::core::paths::state_dir_read_only().unwrap();
858        let cache = crate::core::paths::cache_dir_read_only().unwrap();
859        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state);
860        crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache);
861        assert_eq!(before, store_guard(&home).unwrap().render().unwrap());
862    }
863
864    #[cfg(target_os = "macos")]
865    #[test]
866    fn pinned_rules_are_required_consistent_and_never_substituted() {
867        let isolation = crate::core::data_dir::isolated_data_dir();
868        let live = isolation.path().to_path_buf();
869        let valid = store_guard(&dirs::home_dir().unwrap())
870            .unwrap()
871            .render()
872            .unwrap();
873        // Unpins on drop, including on a failing assertion below.
874        let _session = SyntheticSession;
875        unpin_synthetic_session();
876
877        // Community: no authority, no rules, no wrapping, no error.
878        assert!(protected_child_prefix().unwrap().is_none());
879        assert!(protected_store_denies().unwrap().is_none());
880
881        // Rules without the launch authority are an inconsistency, not a session.
882        crate::test_env::set_var(CHILD_PROFILE_ENV, &valid);
883        assert!(protected_child_prefix().is_err());
884
885        // Half an authority authorizes nothing.
886        crate::test_env::set_var(REQUIRED_POLICY_ROOT_ENV, "/synthetic/project");
887        assert!(protected_child_prefix().is_err());
888        crate::test_env::set_var(REQUIRED_POLICY_DIGEST_ENV, "synthetic-digest");
889
890        // The complete pin wraps through the absolute system binary, inline.
891        let prefix = protected_child_prefix().unwrap().unwrap();
892        assert_eq!(
893            prefix,
894            vec![SANDBOX_EXEC.to_string(), "-p".into(), valid.clone()]
895        );
896        // A nested `(deny default)` profile receives denials only.
897        let denials = protected_store_denies().unwrap().unwrap();
898        assert!(denials.lines().all(|line| line.starts_with("(deny ")));
899        assert!(denials.contains(&deny_read_rule(live.to_str().unwrap())));
900
901        // No fallback and no substitution: a bare profile, a foreign document and
902        // rules that no longer cover the live store all refuse the call.
903        for substitute in [
904            format!("{valid}(allow file-read*)\n"),
905            valid.replace("(deny file-read*", ";(deny file-read*"),
906            PROFILE_HEADER.to_string(),
907            "(version 1)\n(deny default)\n".to_string(),
908            format!("{PROFILE_HEADER}(deny file-read* (subpath \"/synthetic/elsewhere\"))\n"),
909        ] {
910            crate::test_env::set_var(CHILD_PROFILE_ENV, &substitute);
911            assert!(protected_child_prefix().is_err(), "{substitute}");
912            assert!(protected_store_denies().is_err(), "{substitute}");
913        }
914
915        // A protected session that produced no rules at all runs nothing.
916        crate::test_env::remove_var(CHILD_PROFILE_ENV);
917        assert!(protected_child_prefix().is_err());
918    }
919
920    #[cfg(target_os = "macos")]
921    #[test]
922    fn real_kernel_denies_store_access_but_preserves_parent_writes_and_project_work() {
923        let temp = tempfile::tempdir().unwrap();
924        let root = temp.path().canonicalize().unwrap();
925        let store = root.join("data");
926        let fact = store.join("knowledge/knowledge.json");
927        std::fs::create_dir_all(store.join("knowledge")).unwrap();
928        std::fs::create_dir_all(store.join("build-cache")).unwrap();
929        std::fs::create_dir_all(root.join("project")).unwrap();
930        std::fs::write(&fact, "synthetic-provider-fact").unwrap();
931        for member in [
932            "keys/fixture.key",
933            "journal-rotated.md",
934            "new-context-store/value.json",
935            "packages/fixture.ctx.json",
936        ] {
937            let path = store.join(member);
938            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
939            std::fs::write(path, "synthetic-protected-value").unwrap();
940        }
941        let profile = synthetic_store_profile(&[store.clone(), root.join("dormant")]);
942        validate(&profile).unwrap();
943
944        let nested = "/usr/bin/sandbox-exec -p '(version 1)(allow default)'";
945        let nested_project = format!("{nested} /bin/cat project/main.rs");
946        let nested_store = format!("{nested} /bin/cat data/knowledge/knowledge.json");
947        for (command, success) in [
948            ("cat data/knowledge/knowledge.json", false),
949            ("cat data/keys/fixture.key", false),
950            ("cat data/journal-rotated.md", false),
951            ("cat data/new-context-store/value.json", false),
952            ("cat data/packages/fixture.ctx.json", false),
953            ("cp data/knowledge/knowledge.json stolen.json", false),
954            ("ls data/knowledge", false),
955            ("printf downgraded > data/knowledge/knowledge.json", false),
956            ("printf revived > data/knowledge/planted.json", false),
957            ("rm data/knowledge/knowledge.json", false),
958            ("mv data/knowledge data/moved", false),
959            (
960                "ln data/keys/fixture.key data/build-cache/copied.key",
961                false,
962            ),
963            ("mkdir data/memory", false),
964            (
965                "mkdir data/sessions && printf x > data/sessions/s.json",
966                false,
967            ),
968            // A dormant layout root may not be planted as a link either.
969            ("mkdir elsewhere && ln -s elsewhere dormant", false),
970            // Ordinary project work and the shared build cache are untouched.
971            (
972                "printf 'fn main() {}' > project/main.rs && cat project/main.rs",
973                true,
974            ),
975            (
976                "printf object > data/build-cache/out.o && cat data/build-cache/out.o",
977                true,
978            ),
979            // macOS refuses reapplication from this restricted process. Ordinary
980            // project execution is proved above; nested sandbox failure must
981            // never be mistaken for a usable execution path.
982            (nested_project.as_str(), false),
983            (nested_store.as_str(), false),
984        ] {
985            let output = Command::new(SANDBOX_EXEC)
986                .args(["-p", &profile, "/bin/sh", "-c", command])
987                .current_dir(&root)
988                .output()
989                .unwrap();
990            assert_eq!(
991                output.status.success(),
992                success,
993                "{command}: {}",
994                String::from_utf8_lossy(&output.stderr)
995            );
996        }
997        assert!(!store.join("moved").exists());
998        assert!(!store.join("knowledge/planted.json").exists());
999        assert!(!root.join("stolen.json").exists());
1000        // The Engine persists from outside this profile and is unaffected: the
1001        // fact survived every attempt, and a fresh write still lands.
1002        assert_eq!(
1003            std::fs::read_to_string(&fact).unwrap(),
1004            "synthetic-provider-fact"
1005        );
1006        std::fs::write(&fact, "engine-write").unwrap();
1007        assert_eq!(std::fs::read_to_string(&fact).unwrap(), "engine-write");
1008    }
1009}