1use 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";
11const PROFILE_HEADER: &str = "(version 1)\n(allow default)\n";
13const MAX_ENTRIES: usize = 4096;
14const MAX_PROFILE_BYTES: usize = 128 * 1024;
15
16pub const CHILD_PROFILE_ENV: &str = "LEAN_CTX_PROTECTED_CHILD_PROFILE";
21
22const STORE_WORK_MEMBERS: &[&str] = &["build-cache", "resources"];
26
27pub 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 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 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
130pub 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
160pub 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 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 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 result.push_str("(deny mach-task-read)\n(deny mach-task-name)\n");
263 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 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
320fn 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
349fn 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
373pub 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
393pub 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 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 for work in [&path, &resolved] {
441 guard.ancestors.insert(checked_path(work)?);
442 }
443 }
444 }
445 Ok(())
446}
447
448pub 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#[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
478fn 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 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 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#[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
572pub 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 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 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 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 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 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 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 let _session = SyntheticSession;
875 unpin_synthetic_session();
876
877 assert!(protected_child_prefix().unwrap().is_none());
879 assert!(protected_store_denies().unwrap().is_none());
880
881 crate::test_env::set_var(CHILD_PROFILE_ENV, &valid);
883 assert!(protected_child_prefix().is_err());
884
885 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 let prefix = protected_child_prefix().unwrap().unwrap();
892 assert_eq!(
893 prefix,
894 vec![SANDBOX_EXEC.to_string(), "-p".into(), valid.clone()]
895 );
896 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 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 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 ("mkdir elsewhere && ln -s elsewhere dormant", false),
970 (
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 (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 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}