1use std::path::{Path, PathBuf};
42
43pub const DEFAULT_EGRESS: &[&str] = &["api.anthropic.com:443", "*.anthropic.com:443"];
45
46#[derive(Debug, Clone)]
48pub struct SandboxInputs {
49 pub enforce: crate::types::SandboxEnforce,
50 pub session_cwd: PathBuf,
51 pub mission_dir: PathBuf,
52 pub tmpdir: PathBuf,
61 pub extra_write: Vec<PathBuf>,
62 pub egress: Vec<String>,
63 pub validator_read_deny_roots: Vec<PathBuf>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SandboxBackend {
80 Seatbelt,
81 Bubblewrap,
82 AppContainer,
85 Container,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum SandboxDecision {
93 Off,
95 Enforce(SandboxBackend),
97 UnsupportedWarn,
100}
101
102fn enforce_label(enforce: crate::types::SandboxEnforce) -> &'static str {
103 match enforce {
104 crate::types::SandboxEnforce::Off => "off",
105 crate::types::SandboxEnforce::Fs => "fs",
106 crate::types::SandboxEnforce::FsNet => "fs+net",
107 }
108}
109
110pub fn platform_support(enforce: crate::types::SandboxEnforce, target_os: &str) -> SandboxDecision {
115 match enforce {
116 crate::types::SandboxEnforce::Off => SandboxDecision::Off,
117 crate::types::SandboxEnforce::Fs if target_os == "macos" => {
118 SandboxDecision::Enforce(SandboxBackend::Seatbelt)
119 }
120 crate::types::SandboxEnforce::FsNet if target_os == "macos" => {
121 SandboxDecision::Enforce(SandboxBackend::Seatbelt)
125 }
126 crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet
127 if target_os == "linux" =>
128 {
129 SandboxDecision::Enforce(SandboxBackend::Bubblewrap)
130 }
131 crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet
132 if target_os == "windows" =>
133 {
134 SandboxDecision::Enforce(SandboxBackend::AppContainer)
135 }
136 crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet => {
137 SandboxDecision::UnsupportedWarn
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
144pub struct ResolvedSandbox {
145 pub backend: SandboxBackend,
146 pub inputs: SandboxInputs,
147 pub container: Option<crate::sandbox_container::ContainerSpec>,
149}
150
151pub(crate) fn expand_tilde(raw: &str) -> PathBuf {
162 let rest = raw.strip_prefix("~/").or_else(|| {
163 if cfg!(windows) {
164 raw.strip_prefix("~\\")
165 } else {
166 None
167 }
168 });
169 if let Some(rest) = rest {
170 if let Some(home) = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
171 .filter(|value| !value.is_empty())
172 {
173 return PathBuf::from(home).join(rest);
174 }
175 }
176 PathBuf::from(raw)
177}
178
179pub fn resolve_for_session(
187 role_sandbox: &crate::types::SandboxConfig,
188 session_cwd: &Path,
189 mission_dir: &Path,
190) -> (Option<ResolvedSandbox>, Option<String>) {
191 let runtime = crate::sandbox_container::detect();
192 let resolved = resolve_for_session_target(
193 role_sandbox,
194 session_cwd,
195 mission_dir,
196 std::env::consts::OS,
197 command_available("bwrap"),
198 runtime,
199 session_mount_proof(role_sandbox, session_cwd, mission_dir, runtime),
200 );
201 prewarm_xcrun_for_resolved_seatbelt(resolved.0.as_ref());
202 resolved
203}
204
205#[cfg(target_os = "macos")]
211fn prewarm_xcrun_for_resolved_seatbelt(sandbox: Option<&ResolvedSandbox>) {
212 if sandbox.is_some_and(|sandbox| sandbox.backend == SandboxBackend::Seatbelt) {
213 crate::command_exec::prewarm_xcrun_cache_outside_sandbox();
214 }
215}
216
217#[cfg(not(target_os = "macos"))]
218fn prewarm_xcrun_for_resolved_seatbelt(_sandbox: Option<&ResolvedSandbox>) {}
219
220pub(crate) fn session_mount_proof(
226 role_sandbox: &crate::types::SandboxConfig,
227 session_cwd: &Path,
228 mission_dir: &Path,
229 runtime: Option<crate::sandbox_container::ContainerRuntime>,
230) -> Option<crate::sandbox_container::MountProof> {
231 if role_sandbox.provider != crate::types::SandboxProvider::Container
232 || role_sandbox.enforce == crate::types::SandboxEnforce::Off
233 || cfg!(target_os = "linux")
234 || cfg!(target_os = "windows")
235 {
236 return None;
237 }
238 let runtime = runtime?;
239 let extra_write: Vec<PathBuf> = role_sandbox
240 .extra_write
241 .iter()
242 .map(|raw| expand_tilde(raw))
243 .collect();
244 Some(crate::sandbox_container::prove_mount_roots(
245 runtime,
246 &crate::sandbox_container::declared_mount_roots(session_cwd, mission_dir, &extra_write),
247 crate::sandbox_container::DEFAULT_IMAGE,
248 ))
249}
250
251fn resolve_for_session_target(
252 role_sandbox: &crate::types::SandboxConfig,
253 session_cwd: &Path,
254 mission_dir: &Path,
255 target_os: &str,
256 bwrap_available: bool,
257 container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
258 container_mount_proof: Option<crate::sandbox_container::MountProof>,
259) -> (Option<ResolvedSandbox>, Option<String>) {
260 if role_sandbox.provider == crate::types::SandboxProvider::Container {
261 return resolve_container_target(
262 role_sandbox,
263 session_cwd,
264 mission_dir,
265 target_os,
266 container_runtime,
267 container_mount_proof,
268 );
269 }
270 match platform_support(role_sandbox.enforce, target_os) {
271 SandboxDecision::Off => (None, None),
272 SandboxDecision::UnsupportedWarn => (
273 None,
274 Some(format!(
275 "sandbox enforce:{} requested but unsupported on target_os={target_os}; refusing to run unsandboxed",
276 enforce_label(role_sandbox.enforce)
277 )),
278 ),
279 SandboxDecision::Enforce(SandboxBackend::Bubblewrap) if !bwrap_available => (
280 None,
281 Some(format!(
282 "sandbox enforce:{} requested on linux but `bwrap` was not found; refusing to run unsandboxed",
283 enforce_label(role_sandbox.enforce)
284 )),
285 ),
286 SandboxDecision::Enforce(backend) => (
287 Some(ResolvedSandbox {
288 backend,
289 inputs: build_inputs(role_sandbox, session_cwd, mission_dir),
290 container: None,
291 }),
292 None,
293 ),
294 }
295}
296
297fn resolve_container_target(
304 role_sandbox: &crate::types::SandboxConfig,
305 session_cwd: &Path,
306 mission_dir: &Path,
307 target_os: &str,
308 runtime: Option<crate::sandbox_container::ContainerRuntime>,
309 mount_proof: Option<crate::sandbox_container::MountProof>,
310) -> (Option<ResolvedSandbox>, Option<String>) {
311 if role_sandbox.enforce == crate::types::SandboxEnforce::Off {
312 return (None, None);
313 }
314 if target_os == "windows" {
325 return (
326 None,
327 Some(format!(
328 "sandbox provider:container with enforce:{} is not supported on target_os=windows: the shipped contract uses POSIX guest paths, Linux images, and /dev/null authority masks that Windows containers do not honor; refusing to run under an unverified container mount contract",
329 enforce_label(role_sandbox.enforce)
330 )),
331 );
332 }
333 if target_os != "linux" {
334 match mount_proof {
335 Some(crate::sandbox_container::MountProof::Proven) => {}
336 Some(crate::sandbox_container::MountProof::Failed(reason)) => {
337 return (
338 None,
339 Some(format!(
340 "sandbox provider:container with enforce:{} refused on target_os={target_os}: {reason}",
341 enforce_label(role_sandbox.enforce)
342 )),
343 );
344 }
345 None => {
346 return (
347 None,
348 Some(format!(
349 "sandbox provider:container with enforce:{} on target_os={target_os} requires a bind-mount proof on this host and none was taken; refusing to run under an unverified container mount contract; use sandbox.provider=\"process\" for native host containment",
350 enforce_label(role_sandbox.enforce)
351 )),
352 );
353 }
354 }
355 }
356 let Some(runtime) = runtime else {
357 return (
358 None,
359 Some(
360 "sandbox provider:container requested but no container runtime (docker/podman/nerdctl/container) found on PATH; refusing to run unsandboxed"
361 .to_string(),
362 ),
363 );
364 };
365 if role_sandbox.enforce == crate::types::SandboxEnforce::FsNet
366 && !role_sandbox.egress.is_empty()
367 && runtime != crate::sandbox_container::ContainerRuntime::Docker
368 {
369 return (
370 None,
371 Some(format!(
372 "sandbox provider:container with enforce:fs+net and a non-empty egress list requires Docker's internal-network boundary; runtime {} is not live-proven for that posture — refusing to run",
373 runtime.binary()
374 )),
375 );
376 }
377 (
378 Some(ResolvedSandbox {
379 backend: SandboxBackend::Container,
380 inputs: build_inputs(role_sandbox, session_cwd, mission_dir),
381 container: Some(crate::sandbox_container::ContainerSpec {
382 runtime,
383 image: role_sandbox
384 .image
385 .clone()
386 .unwrap_or_else(|| crate::sandbox_container::DEFAULT_IMAGE.to_string()),
387 network: None,
388 name: None,
389 }),
390 }),
391 None,
392 )
393}
394
395fn build_inputs(
396 role_sandbox: &crate::types::SandboxConfig,
397 session_cwd: &Path,
398 mission_dir: &Path,
399) -> SandboxInputs {
400 let extra_write = role_sandbox
401 .extra_write
402 .iter()
403 .map(|s| expand_tilde(s))
404 .collect();
405 SandboxInputs {
406 enforce: role_sandbox.enforce,
407 session_cwd: session_cwd.to_path_buf(),
408 mission_dir: mission_dir.to_path_buf(),
409 tmpdir: mission_dir.join("runs").join("contract-home"),
417 extra_write,
418 egress: role_sandbox.egress.clone(),
419 validator_read_deny_roots: Vec::new(),
422 }
423}
424
425#[derive(Debug)]
437pub struct ValidatorContainment {
438 pub sandbox: Option<ResolvedSandbox>,
443 pub note: Option<String>,
456}
457
458pub fn resolve_validator_containment(
487 role_sandbox: &crate::types::SandboxConfig,
488 backend: crate::types::BackendKind,
489 session_cwd: &Path,
490 mission_dir: &Path,
491 read_deny_roots: &[PathBuf],
492 allow_uncontained_degrade: bool,
493) -> crate::error::Result<ValidatorContainment> {
494 let runtime = crate::sandbox_container::detect();
495 let resolved = resolve_validator_containment_target(
496 role_sandbox,
497 backend,
498 session_cwd,
499 mission_dir,
500 read_deny_roots,
501 allow_uncontained_degrade,
502 std::env::consts::OS,
503 command_available("bwrap"),
504 runtime,
505 session_mount_proof(role_sandbox, session_cwd, mission_dir, runtime),
506 );
507 if let Ok(containment) = &resolved {
508 prewarm_xcrun_for_resolved_seatbelt(containment.sandbox.as_ref());
509 }
510 resolved
511}
512
513#[allow(clippy::too_many_arguments)]
518fn resolve_validator_containment_target(
519 role_sandbox: &crate::types::SandboxConfig,
520 backend: crate::types::BackendKind,
521 session_cwd: &Path,
522 mission_dir: &Path,
523 read_deny_roots: &[PathBuf],
524 allow_uncontained_degrade: bool,
525 target_os: &str,
526 bwrap_available: bool,
527 container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
528 container_mount_proof: Option<crate::sandbox_container::MountProof>,
529) -> crate::error::Result<ValidatorContainment> {
530 if role_sandbox.enforce != crate::types::SandboxEnforce::Off {
531 let (sandbox, warn) = resolve_for_session_target(
535 role_sandbox,
536 session_cwd,
537 mission_dir,
538 target_os,
539 bwrap_available,
540 container_runtime,
541 container_mount_proof,
542 );
543 return match sandbox {
544 Some(mut resolved) => {
545 if resolved.backend != SandboxBackend::Container {
549 resolved.inputs.validator_read_deny_roots = read_deny_roots.to_vec();
550 }
551 Ok(ValidatorContainment {
552 sandbox: Some(resolved),
553 note: None,
554 })
555 }
556 None => Err(crate::error::EngineError::Backend(warn.unwrap_or_else(|| {
559 format!(
560 "sandbox enforce:{} requested but no sandbox could be resolved; refusing to run unsandboxed",
561 role_sandbox.enforce.as_str()
562 )
563 }))),
564 };
565 }
566
567 let uncontained = |why: String, note: String| -> crate::error::Result<ValidatorContainment> {
579 if !allow_uncontained_degrade {
580 return Err(crate::error::EngineError::Config(format!(
581 "mandatory validator containment cannot apply ({why}); refusing to run an \
582 uncontained validator — the degraded posture reopens the modify→use→restore \
583 path the wrap exists to close (ticket \
584 validator-containment-degrade-fail-closed). To run validators here anyway, \
585 set \"validatorAllowUncontainedDegrade\": true in .kranz/config.json (the \
586 loud per-round degrade returns); otherwise use a containable platform \
587 (macOS, or linux with `bwrap` on PATH) and the claude validator backend"
588 )));
589 }
590 Ok(ValidatorContainment {
591 sandbox: None,
592 note: Some(note),
593 })
594 };
595 if !backend.supports_sandbox_enforcement() {
596 return uncontained(
597 format!(
598 "the {} backend does not apply the resolved sandbox profile",
599 backend.as_str()
600 ),
601 format!(
602 "validator sessions on the {} backend cannot be OS-sandbox-contained (only the \
603 claude backend applies the resolved sandbox profile); \
604 validatorAllowUncontainedDegrade is set, so this validator runs with \
605 snapshot isolation and the after-fingerprint tripwire only — the real checkout \
606 is reachable from the session. Select a claude validator backend for mandatory \
607 containment (ticket validator-mandatory-containment; the degrade is opt-in per \
608 validator-containment-degrade-fail-closed)",
609 backend.as_str()
610 ),
611 );
612 }
613 let degraded = |why: String| {
614 uncontained(
615 why.clone(),
616 format!(
617 "validator sessions are NOT OS-sandbox-contained ({why}); \
618 validatorAllowUncontainedDegrade is set, so the validator still runs in its \
619 throwaway snapshot with the after-fingerprint tripwire on the real checkout, \
620 but hostile validator code could walk to the real checkout and restore bytes \
621 before the fingerprint — containment here is the snapshot's physical \
622 separation only (ticket validator-mandatory-containment; the degrade is \
623 opt-in per validator-containment-degrade-fail-closed)"
624 ),
625 )
626 };
627 match platform_support(crate::types::SandboxEnforce::Fs, target_os) {
628 SandboxDecision::Off => unreachable!("fs never decides Off"),
631 SandboxDecision::UnsupportedWarn => {
632 degraded(format!("target_os={target_os} has no process-sandbox tier"))
633 }
634 SandboxDecision::Enforce(SandboxBackend::Bubblewrap) if !bwrap_available => {
635 degraded("linux without `bwrap` on PATH".to_string())
636 }
637 SandboxDecision::Enforce(SandboxBackend::Container) => {
641 unreachable!("process tier only")
642 }
643 SandboxDecision::Enforce(backend_kind) => Ok(ValidatorContainment {
644 sandbox: Some(ResolvedSandbox {
645 backend: backend_kind,
646 inputs: SandboxInputs {
647 enforce: crate::types::SandboxEnforce::Fs,
652 session_cwd: session_cwd.to_path_buf(),
653 mission_dir: mission_dir.to_path_buf(),
654 tmpdir: mission_dir.join("runs").join("contract-home"),
659 extra_write: Vec::new(),
663 egress: Vec::new(),
664 validator_read_deny_roots: read_deny_roots.to_vec(),
665 },
666 container: None,
667 }),
668 note: None,
669 }),
670 }
671}
672
673pub(crate) fn command_available(name: &str) -> bool {
674 let Some(path) = std::env::var_os("PATH") else {
675 return false;
676 };
677 let mut candidates = vec![name.to_string()];
681 if cfg!(windows) {
682 let pathext =
683 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
684 candidates.extend(
685 pathext
686 .split(';')
687 .filter(|ext| !ext.is_empty())
688 .map(|ext| format!("{name}{ext}")),
689 );
690 }
691 std::env::split_paths(&path).any(|dir| candidates.iter().any(|name| dir.join(name).is_file()))
692}
693
694pub(crate) fn absolutize(path: &Path) -> PathBuf {
697 if let Ok(canon) = path.canonicalize() {
698 return canon;
699 }
700 for ancestor in path.ancestors().skip(1) {
703 if let Ok(canon) = ancestor.canonicalize() {
704 if let Ok(suffix) = path.strip_prefix(ancestor) {
705 return canon.join(suffix);
706 }
707 }
708 }
709 if path.is_absolute() {
710 path.to_path_buf()
711 } else {
712 std::env::current_dir()
713 .map(|cwd| cwd.join(path))
714 .unwrap_or_else(|_| path.to_path_buf())
715 }
716}
717
718fn lexical_absolute(path: &Path) -> PathBuf {
720 if path.is_absolute() {
721 path.to_path_buf()
722 } else {
723 std::env::current_dir()
724 .map(|cwd| cwd.join(path))
725 .unwrap_or_else(|_| path.to_path_buf())
726 }
727}
728
729pub(crate) fn global_authority_dir() -> Option<PathBuf> {
730 crate::paths::global_config().and_then(|path| path.parent().map(absolutize))
731}
732
733pub(crate) fn escape_sbpl_literal(path: &Path) -> String {
735 escape_sbpl_string(&path.to_string_lossy())
736}
737
738fn escape_sbpl_string(s: &str) -> String {
739 s.replace('\\', "\\\\").replace('"', "\\\"")
740}
741
742pub(crate) fn escape_sbpl_regex(path: &Path) -> String {
747 let mut out = String::new();
748 for ch in path.to_string_lossy().chars() {
749 match ch {
750 '\\' => out.push_str("\\\\"),
751 '"' => out.push_str("\\\""),
752 c if "^.+$*?()[]{}|".contains(c) => {
753 out.push('\\');
754 out.push(c);
755 }
756 c => out.push(c),
757 }
758 }
759 out
760}
761
762pub(crate) fn write_allowlist(inputs: &SandboxInputs) -> Vec<PathBuf> {
772 let mut write_paths: Vec<PathBuf> =
773 vec![absolutize(&inputs.session_cwd), absolutize(&inputs.tmpdir)];
774 write_paths.extend(inputs.extra_write.iter().map(|p| absolutize(p)));
775 write_paths.sort();
776 write_paths.dedup();
777 write_paths
778}
779
780pub(crate) struct MissionWriteDenies {
790 pub files: Vec<PathBuf>,
793 pub control_dirs: Vec<PathBuf>,
795 pub runs_dirs: Vec<PathBuf>,
799}
800
801pub(crate) const MISSION_METADATA_FILES: &[&str] = &[
804 "events.jsonl",
805 "events.jsonl.lock",
806 "state.json",
807 "state.json.tmp",
808 "estimate.json",
809];
810
811pub(crate) fn mission_write_denies(inputs: &SandboxInputs) -> MissionWriteDenies {
812 let mut denies = MissionWriteDenies {
813 files: Vec::new(),
814 control_dirs: Vec::new(),
815 runs_dirs: Vec::new(),
816 };
817 let mut mission_dirs = vec![inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)];
818 if let Some(missions) = inputs
821 .mission_dir
822 .parent()
823 .filter(|path| path.ends_with("missions"))
824 {
825 if let Ok(entries) = std::fs::read_dir(missions) {
826 for entry in entries.flatten() {
827 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
828 mission_dirs.extend([entry.path(), absolutize(&entry.path())]);
829 }
830 }
831 }
832 }
833 for mission_dir in mission_dirs {
834 for name in MISSION_METADATA_FILES {
835 denies.files.push(mission_dir.join(name));
836 }
837 denies.control_dirs.push(mission_dir.join("control"));
838 denies.runs_dirs.push(mission_dir.join("runs"));
839 }
840 denies
841}
842
843fn operator_cargo_home() -> Option<PathBuf> {
850 std::env::var_os("CARGO_HOME")
851 .map(PathBuf::from)
852 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))
853}
854
855pub(crate) fn cargo_cache_write_deny_paths() -> Vec<PathBuf> {
871 let Some(cargo_home) = operator_cargo_home() else {
872 return Vec::new();
873 };
874 let mut paths = Vec::with_capacity(4);
875 for base in [cargo_home.clone(), absolutize(&cargo_home)] {
876 paths.push(base.join("registry"));
877 paths.push(base.join("git"));
878 }
879 paths
880}
881
882pub(crate) fn authority_read_deny_paths(inputs: &SandboxInputs) -> Vec<PathBuf> {
903 let mut paths = Vec::new();
904 for mission_dir in [inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)] {
905 if let Some(kranz_dir) = mission_dir
906 .parent()
907 .filter(|path| path.ends_with("missions"))
908 .and_then(Path::parent)
909 {
910 for name in KRANZ_AUTHORITY_FILES {
911 paths.push(kranz_dir.join(name));
912 }
913 }
914 }
915 for name in [
916 "serve.token",
917 "serve.read.token",
918 "config.json",
919 "domain-terms.local",
920 ] {
921 paths.push(absolutize(&inputs.session_cwd).join(".kranz").join(name));
922 }
923 if let Some(global) = crate::paths::global_config() {
924 paths.extend([global.clone(), absolutize(&global)]);
925 }
926 if let Some(cargo_home) = operator_cargo_home() {
927 for base in [cargo_home.clone(), absolutize(&cargo_home)] {
928 paths.push(base.join("credentials.toml"));
929 paths.push(base.join("credentials"));
930 }
931 }
932 paths.extend(paths.clone().iter().map(|path| absolutize(path)));
935 paths.sort();
936 paths.dedup();
937 paths
938}
939
940pub(crate) fn authority_read_deny_dirs(inputs: &SandboxInputs) -> Vec<PathBuf> {
960 let mut dirs = Vec::new();
961 for mission_dir in [inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)] {
962 dirs.push(mission_dir.join("control"));
963 if let Some(kranz_dir) = mission_dir
964 .parent()
965 .filter(|path| path.ends_with("missions"))
966 .and_then(Path::parent)
967 {
968 dirs.push(kranz_dir.join("hook-status"));
969 }
970 }
971 if let Some(global) = crate::paths::global_kranz_dir() {
972 dirs.extend([global.clone(), absolutize(&global)]);
973 }
974 for keys_dir in global_key_dirs() {
975 dirs.push(keys_dir);
976 }
977 dirs
978}
979
980pub(crate) struct AuthorityDirectoryMask {
985 pub path: PathBuf,
986 pub visible_entries: Vec<PathBuf>,
987}
988
989pub(crate) fn authority_directory_masks(inputs: &SandboxInputs) -> Vec<AuthorityDirectoryMask> {
990 let files: std::collections::BTreeSet<_> = authority_read_deny_paths(inputs)
991 .into_iter()
992 .filter_map(|path| Some(absolutize(path.parent()?).join(path.file_name()?)))
993 .collect();
994 let dirs: std::collections::BTreeSet<_> = authority_read_deny_dirs(inputs)
995 .iter()
996 .map(|path| absolutize(path))
997 .collect();
998 let mut roots: std::collections::BTreeSet<_> = files
999 .iter()
1000 .filter_map(|path| path.parent().map(Path::to_path_buf))
1001 .collect();
1002 for dir in &dirs {
1006 if !roots.contains(dir) {
1007 if let Some(parent) = dir.parent() {
1008 roots.insert(parent.to_path_buf());
1009 }
1010 }
1011 }
1012 let writable = write_allowlist(inputs);
1013 for path in authority_read_deny_paths(inputs)
1018 .into_iter()
1019 .chain(authority_read_deny_dirs(inputs))
1020 {
1021 for ancestor in path.ancestors() {
1022 if std::fs::symlink_metadata(ancestor)
1023 .is_ok_and(|metadata| metadata.file_type().is_symlink())
1024 {
1025 if let Some(parent) = ancestor.parent().map(absolutize) {
1026 if writable.iter().any(|root| parent.starts_with(root)) {
1027 roots.insert(parent);
1028 }
1029 }
1030 }
1031 }
1032 }
1033 let roots: std::collections::BTreeSet<_> = roots
1034 .into_iter()
1035 .map(|path| {
1036 if !path.exists() && !writable.iter().any(|root| path.starts_with(root)) {
1037 path.ancestors()
1041 .skip(1)
1042 .find(|parent| parent.is_dir())
1043 .map(Path::to_path_buf)
1044 .unwrap_or(path)
1045 } else {
1046 path
1047 }
1048 })
1049 .collect();
1050 roots
1051 .into_iter()
1052 .map(|path| {
1053 let mut visible_entries = Vec::new();
1054 if !dirs.iter().any(|dir| path.starts_with(dir)) {
1055 if let Ok(entries) = std::fs::read_dir(&path) {
1056 for entry in entries.flatten() {
1057 let entry_path = entry.path();
1058 if entry
1061 .file_type()
1062 .is_ok_and(|kind| kind.is_dir() || kind.is_file())
1063 && !files.contains(&entry_path)
1064 && !dirs.iter().any(|dir| entry_path.starts_with(dir))
1065 {
1066 visible_entries.push(entry_path);
1067 }
1068 }
1069 }
1070 }
1071 visible_entries.sort();
1072 AuthorityDirectoryMask {
1073 path,
1074 visible_entries,
1075 }
1076 })
1077 .collect()
1078}
1079
1080fn global_key_dirs() -> Vec<PathBuf> {
1088 let Some(global) = crate::paths::global_kranz_dir() else {
1089 return Vec::new();
1090 };
1091 let mut out = Vec::new();
1092 for name in ["keys", "seals"] {
1093 let dir = global.join(name);
1094 let canonical = absolutize(&dir);
1095 if canonical != dir {
1096 out.push(canonical);
1097 }
1098 out.push(dir);
1099 }
1100 out
1101}
1102
1103const KRANZ_ENGINE_OWNED_DIRS: &[&str] = &["queue", "tickets", "lessons", "hook-status"];
1108
1109const KRANZ_AUTHORITY_FILES: &[&str] = &[
1115 "serve.token",
1116 "serve.read.token",
1117 "config.json",
1118 "domain-terms.local",
1119];
1120
1121pub(crate) fn kranz_authority_entries(kranz_dir: &Path) -> WriteDenySet {
1125 WriteDenySet {
1126 files: KRANZ_AUTHORITY_FILES
1127 .iter()
1128 .map(|name| kranz_dir.join(name))
1129 .collect(),
1130 dirs: KRANZ_ENGINE_OWNED_DIRS
1131 .iter()
1132 .map(|name| kranz_dir.join(name))
1133 .collect(),
1134 }
1135}
1136
1137fn repo_kranz_dirs(inputs: &SandboxInputs) -> Vec<(PathBuf, PathBuf, PathBuf)> {
1150 let mut out = Vec::new();
1151 for mission_dir in [inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)] {
1152 let Some(missions_dir) = mission_dir.parent().map(Path::to_path_buf) else {
1153 continue;
1154 };
1155 let Some(kranz_dir) = missions_dir.parent().map(Path::to_path_buf) else {
1156 continue;
1157 };
1158 if missions_dir.file_name() != Some(std::ffi::OsStr::new("missions"))
1159 || kranz_dir.file_name() != Some(std::ffi::OsStr::new(".kranz"))
1160 {
1161 continue;
1162 }
1163 out.push((kranz_dir, missions_dir, mission_dir));
1164 }
1165 out
1166}
1167
1168pub(crate) struct WriteDenySet {
1173 pub files: Vec<PathBuf>,
1174 pub dirs: Vec<PathBuf>,
1175}
1176
1177fn sorted_dedup(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
1178 paths.sort();
1179 paths.dedup();
1180 paths
1181}
1182
1183pub(crate) fn authority_write_denies(inputs: &SandboxInputs) -> WriteDenySet {
1221 let mut files = authority_read_deny_paths(inputs);
1222 let mut dirs = authority_read_deny_dirs(inputs);
1223 for (kranz_dir, missions_dir, mission_dir) in repo_kranz_dirs(inputs) {
1224 let entries = kranz_authority_entries(&kranz_dir);
1225 files.extend(entries.files);
1226 dirs.extend(entries.dirs);
1227 if let Ok(entries) = std::fs::read_dir(&kranz_dir) {
1228 for entry in entries.flatten() {
1229 if entry.file_name().as_os_str() == std::ffi::OsStr::new("missions") {
1232 continue;
1233 }
1234 let path = entry.path();
1235 match std::fs::symlink_metadata(&path) {
1236 Ok(metadata) if metadata.file_type().is_dir() => dirs.push(path),
1237 Ok(_) => files.push(path),
1241 Err(_) => {}
1242 }
1243 }
1244 }
1245 if let Ok(entries) = std::fs::read_dir(&missions_dir) {
1246 for entry in entries.flatten() {
1247 let path = entry.path();
1248 if path == mission_dir {
1249 continue;
1250 }
1251 dirs.push(path);
1252 }
1253 }
1254 }
1255 WriteDenySet {
1256 files: sorted_dedup(files),
1257 dirs: sorted_dedup(dirs),
1258 }
1259}
1260
1261pub(crate) fn sealed_kranz_dir_roots(inputs: &SandboxInputs) -> Vec<PathBuf> {
1270 let mut roots = Vec::new();
1271 for (kranz_dir, missions_dir, _) in repo_kranz_dirs(inputs) {
1272 roots.push(kranz_dir);
1273 roots.push(missions_dir);
1274 }
1275 sorted_dedup(roots)
1276}
1277
1278#[cfg(unix)]
1298pub(crate) fn operator_tty_paths() -> Vec<PathBuf> {
1299 let mut paths = Vec::new();
1300 for fd in [0, 1, 2] {
1301 let name = unsafe {
1306 if libc::isatty(fd) != 1 {
1307 continue;
1308 }
1309 let mut buffer = [0 as libc::c_char; 1024];
1310 if libc::ttyname_r(fd, buffer.as_mut_ptr(), buffer.len()) != 0 {
1311 continue;
1312 }
1313 std::ffi::CStr::from_ptr(buffer.as_ptr())
1314 .to_string_lossy()
1315 .into_owned()
1316 };
1317 if name.is_empty() {
1318 continue;
1319 }
1320 let path = PathBuf::from(name);
1321 paths.push(absolutize(&path));
1322 paths.push(path);
1323 }
1324 sorted_dedup(paths)
1325}
1326
1327#[cfg(not(unix))]
1328pub(crate) fn operator_tty_paths() -> Vec<PathBuf> {
1329 Vec::new()
1330}
1331
1332pub(crate) fn tty_deny_block(paths: &[PathBuf]) -> String {
1341 let literals: std::collections::BTreeSet<String> =
1342 paths.iter().map(|path| escape_sbpl_literal(path)).collect();
1343 if literals.is_empty() {
1344 return String::new();
1345 }
1346 let mut block = String::from("(deny file-read* file-write* file-ioctl\n");
1347 for literal in &literals {
1348 block.push_str(&format!(" (literal \"{literal}\")\n"));
1349 }
1350 block.push_str(")\n");
1351 block
1352}
1353
1354pub(crate) fn validate_git_config_protection(
1358 inputs: &SandboxInputs,
1359 mount_based: bool,
1360) -> crate::error::Result<()> {
1361 let writable = write_allowlist(inputs);
1362 let neutral_config = absolutize(crate::git_ops::empty_global_config_path()?);
1363 if writable.iter().any(|root| neutral_config.starts_with(root)) {
1364 return Err(crate::error::EngineError::Backend(
1365 "cannot grant sandbox writes over the engine's neutral Git configuration; narrow the overlapping session, scratch, or extraWrite root".into(),
1366 ));
1367 }
1368 let Some(marker) = git_marker(&inputs.session_cwd) else {
1369 return Ok(());
1370 };
1371 let root = marker.parent().expect("git marker has a parent");
1372 let repo = crate::git_ops::GitRepo::open(root)?;
1373 let (git_dir, common, worktree_enabled) = repo.config_protection_paths()?;
1374 let described = git_metadata_dirs(&inputs.session_cwd);
1375 if [&git_dir, &common].iter().any(|dir| {
1376 !described
1377 .iter()
1378 .any(|path| absolutize(path) == absolutize(dir))
1379 }) {
1380 return Err(crate::error::EngineError::Backend(
1381 "cannot protect Git metadata redirected outside the session's Git layout; remove repository environment overrides before running an enforced session".into(),
1382 ));
1383 }
1384 let masks = authority_directory_masks(inputs);
1385 let mut graph_dirs = vec![git_dir.clone(), common.clone()];
1386 graph_dirs.extend(git_metadata_mount_nodes(inputs));
1387 if graph_dirs.iter().any(|dir| {
1388 let dir = absolutize(dir);
1389 if !writable.iter().any(|root| dir.starts_with(root)) {
1392 return false;
1393 }
1394 masks.iter().any(|mask| {
1395 dir.starts_with(&mask.path)
1396 && ![&inputs.session_cwd, &inputs.tmpdir].iter().any(|private| {
1397 let private = absolutize(private);
1398 private != mask.path
1399 && private.starts_with(&mask.path)
1400 && dir.starts_with(private)
1401 })
1402 })
1403 }) {
1404 return Err(crate::error::EngineError::Backend(
1405 "cannot protect Git metadata through an authority directory; keep the Git directory outside .kranz and credential stores".into(),
1406 ));
1407 }
1408 let mut sources = vec![common.join("config")];
1409 if marker.is_file() {
1410 sources.push(marker.clone());
1411 sources.push(git_dir.join("commondir"));
1412 }
1413 if worktree_enabled {
1414 sources.push(git_dir.join("config.worktree"));
1415 }
1416 for source in sources {
1417 let writable_source = writable
1418 .iter()
1419 .any(|root| absolutize(&source).starts_with(root));
1420 for ancestor in source.ancestors() {
1423 if std::fs::symlink_metadata(ancestor).is_ok_and(|meta| meta.file_type().is_symlink())
1424 && (ancestor == source
1425 || writable.iter().any(|root| {
1426 ancestor
1427 .parent()
1428 .map(absolutize)
1429 .map(|parent| parent.join(ancestor.file_name().unwrap_or_default()))
1430 .is_some_and(|path| path.starts_with(root))
1431 }))
1432 {
1433 return Err(crate::error::EngineError::Backend(format!(
1434 "cannot protect Git configuration through symlink {}; use regular Git metadata paths", ancestor.display()
1435 )));
1436 }
1437 }
1438 match std::fs::symlink_metadata(&source) {
1439 Ok(meta) if meta.is_file() => {
1440 #[cfg(unix)]
1441 {
1442 use std::os::unix::fs::MetadataExt;
1443 if meta.nlink() != 1 {
1444 return Err(crate::error::EngineError::Backend(format!(
1445 "cannot protect multiply linked Git configuration {}; replace it with a private regular file", source.display()
1446 )));
1447 }
1448 }
1449 }
1450 Err(error)
1451 if error.kind() == std::io::ErrorKind::NotFound
1452 && (!mount_based || !writable_source) => {}
1453 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1454 return Err(crate::error::EngineError::Backend(format!(
1455 "cannot protect absent active Git configuration {} with a mount sandbox; create the intended regular config file before running, or disable extensions.worktreeConfig", source.display()
1456 )));
1457 }
1458 _ => {
1459 return Err(crate::error::EngineError::Backend(format!(
1460 "cannot protect Git configuration {}; expected a regular file",
1461 source.display()
1462 )))
1463 }
1464 }
1465 }
1466 Ok(())
1467}
1468
1469fn git_marker(cwd: &Path) -> Option<PathBuf> {
1470 cwd.ancestors()
1471 .map(|path| path.join(".git"))
1472 .find(|path| std::fs::symlink_metadata(path).is_ok())
1473}
1474
1475fn git_metadata_dirs(cwd: &Path) -> Vec<PathBuf> {
1478 let Some(marker) = git_marker(cwd) else {
1479 return vec![cwd.join(".git")];
1480 };
1481 let read = |path: &Path| {
1482 std::fs::File::open(path)
1483 .ok()
1484 .and_then(|file| crate::paths::read_regular_file_bounded(file, 16384).ok())
1485 };
1486 if marker.is_dir() {
1487 return vec![marker];
1488 }
1489 let Some(link) = read(&marker) else {
1490 return Vec::new();
1491 };
1492 let Some(path) = link.trim().strip_prefix("gitdir: ") else {
1493 return Vec::new();
1494 };
1495 let dir = absolutize(&marker.parent().unwrap().join(path));
1496 let mut dirs = vec![dir.clone()];
1497 if let Some(common) = read(&dir.join("commondir")) {
1498 dirs.push(absolutize(&dir.join(common.trim())));
1499 }
1500 dirs
1501}
1502
1503pub(crate) fn git_metadata_mount_nodes(inputs: &SandboxInputs) -> Vec<PathBuf> {
1507 let writable = write_allowlist(inputs);
1508 let mut nodes = std::collections::BTreeSet::new();
1509 let mut dirs = git_metadata_dirs(&inputs.session_cwd);
1510 if let Some(marker) = git_marker(&inputs.session_cwd).filter(|path| path.is_file()) {
1511 dirs.push(
1512 marker
1513 .parent()
1514 .expect("git marker has a parent")
1515 .to_path_buf(),
1516 );
1517 }
1518 for dir in dirs {
1519 for path in absolutize(&dir).ancestors() {
1520 if writable
1521 .iter()
1522 .any(|root| path.starts_with(root) && path != root)
1523 && path.is_dir()
1524 {
1525 nodes.insert(path.to_path_buf());
1526 }
1527 }
1528 }
1529 nodes.into_iter().collect()
1530}
1531
1532pub(crate) fn git_metadata_write_denies(inputs: &SandboxInputs) -> WriteDenySet {
1565 let mut files = git_metadata_mount_nodes(inputs);
1566 let mut dirs = Vec::new();
1567 for cwd in [inputs.session_cwd.clone(), absolutize(&inputs.session_cwd)] {
1568 files.push(cwd.join(".git"));
1569 if let Some(marker) = git_marker(&cwd) {
1570 files.push(marker);
1571 }
1572 for git in git_metadata_dirs(&cwd) {
1573 files.push(git.join("config"));
1574 files.push(git.join("config.worktree"));
1575 files.push(git.join("commondir"));
1576 dirs.push(git.join("hooks"));
1577 dirs.push(git.join("info"));
1578 dirs.push(git.join("modules"));
1579 }
1580 }
1581 WriteDenySet {
1582 files: sorted_dedup(files),
1583 dirs: sorted_dedup(dirs),
1584 }
1585}
1586
1587#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1592pub(crate) struct ValidatorReadDenyEntry {
1593 pub path: PathBuf,
1594 pub is_dir: bool,
1595}
1596
1597const VALIDATOR_READ_DENY_CARVEOUTS: &[&str] = &[".git", ".kranz"];
1616
1617pub(crate) fn validator_read_deny_entries(inputs: &SandboxInputs) -> Vec<ValidatorReadDenyEntry> {
1644 let mut entries = std::collections::BTreeSet::new();
1645 for root in &inputs.validator_read_deny_roots {
1646 let Ok(read_dir) = std::fs::read_dir(root) else {
1647 continue;
1648 };
1649 for entry in read_dir.flatten() {
1650 let name = entry.file_name();
1651 if VALIDATOR_READ_DENY_CARVEOUTS.contains(&name.to_string_lossy().as_ref()) {
1652 continue;
1653 }
1654 let path = entry.path();
1655 let Ok(metadata) = std::fs::metadata(&path) else {
1656 continue;
1657 };
1658 let is_dir = metadata.is_dir();
1659 entries.insert(ValidatorReadDenyEntry {
1660 path: path.clone(),
1661 is_dir,
1662 });
1663 let canonical = absolutize(&path);
1664 if canonical != path {
1665 entries.insert(ValidatorReadDenyEntry {
1666 path: canonical,
1667 is_dir,
1668 });
1669 }
1670 }
1671 }
1672 entries.into_iter().collect()
1673}
1674
1675pub fn effective_egress(configured: &[String]) -> Vec<String> {
1678 let mut out: Vec<String> = DEFAULT_EGRESS.iter().map(|s| (*s).to_string()).collect();
1679 for item in configured {
1680 let item = item.trim();
1681 if !item.is_empty() && !out.iter().any(|existing| existing == item) {
1682 out.push(item.to_string());
1683 }
1684 }
1685 out
1686}
1687
1688pub fn generate_profile(inputs: &SandboxInputs) -> String {
1725 let write_paths = write_allowlist(inputs);
1726
1727 let mut profile = String::new();
1728 profile.push_str("(version 1)\n");
1729 profile.push_str("(deny default)\n");
1730 profile.push('\n');
1731 profile.push_str("(allow process*)\n");
1732 profile.push_str("(allow signal (target self))\n");
1733 profile.push_str("(allow sysctl-read)\n");
1734 profile.push_str("(allow mach-lookup)\n");
1735 profile.push_str("(allow mach-register)\n");
1736 profile.push_str("(allow iokit-open)\n");
1737 profile.push('\n');
1738 profile.push_str("(allow file-read*)\n");
1743 profile.push('\n');
1744 let mut deny_literals = std::collections::BTreeSet::new();
1751 for path in authority_read_deny_paths(inputs) {
1752 deny_literals.insert(escape_sbpl_literal(&path));
1753 }
1754 let mut deny_subpaths = std::collections::BTreeSet::new();
1755 for dir in authority_read_deny_dirs(inputs) {
1756 deny_subpaths.insert(escape_sbpl_literal(&dir));
1757 }
1758 if !deny_literals.is_empty() || !deny_subpaths.is_empty() {
1759 profile.push_str("(deny file-read*\n");
1760 for lit in &deny_subpaths {
1761 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
1762 }
1763 for lit in &deny_literals {
1764 profile.push_str(&format!(" (literal \"{lit}\")\n"));
1765 }
1766 profile.push_str(")\n");
1767 profile.push('\n');
1768 }
1769 let validator_denies = validator_read_deny_entries(inputs);
1780 if !validator_denies.is_empty() {
1781 let mut subpaths = std::collections::BTreeSet::new();
1782 let mut literals = std::collections::BTreeSet::new();
1783 for entry in &validator_denies {
1784 if entry.is_dir {
1785 subpaths.insert(escape_sbpl_literal(&entry.path));
1786 } else {
1787 literals.insert(escape_sbpl_literal(&entry.path));
1788 }
1789 }
1790 profile.push_str("(deny file-read*\n");
1791 for lit in &subpaths {
1792 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
1793 }
1794 for lit in &literals {
1795 profile.push_str(&format!(" (literal \"{lit}\")\n"));
1796 }
1797 profile.push_str(")\n");
1798 profile.push('\n');
1799 }
1800 profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
1806 profile.push('\n');
1807 match inputs.enforce {
1808 crate::types::SandboxEnforce::FsNet => {
1809 profile.push_str("(allow network-outbound (remote tcp \"localhost:*\"))\n");
1814 }
1815 crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::Off => {
1819 profile.push_str("(allow network*)\n");
1820 }
1821 }
1822 profile.push('\n');
1823 profile.push_str("(allow file-write*\n");
1826 let mut write_literals = std::collections::BTreeSet::new();
1827 for p in &write_paths {
1828 write_literals.insert(escape_sbpl_literal(p));
1829 }
1830 for raw in [&inputs.session_cwd, &inputs.tmpdir]
1831 .into_iter()
1832 .chain(inputs.extra_write.iter())
1833 {
1834 write_literals.insert(escape_sbpl_literal(raw));
1835 write_literals.insert(escape_sbpl_literal(&absolutize(raw)));
1836 }
1837 for lit in &write_literals {
1838 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
1839 }
1840 profile.push_str(")\n");
1841 profile.push('\n');
1842 let write_denies = mission_write_denies(inputs);
1850 profile.push_str("(deny file-write*\n");
1851 let mut deny_literals = std::collections::BTreeSet::new();
1852 for p in &write_denies.files {
1853 deny_literals.insert(escape_sbpl_literal(p));
1854 }
1855 for lit in &deny_literals {
1856 profile.push_str(&format!(" (literal \"{lit}\")\n"));
1857 }
1858 let mut deny_subpaths = std::collections::BTreeSet::new();
1859 for p in &write_denies.control_dirs {
1860 deny_subpaths.insert(escape_sbpl_literal(p));
1861 }
1862 for lit in &deny_subpaths {
1863 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
1864 }
1865 let mut deny_regexes = std::collections::BTreeSet::new();
1869 for p in &write_denies.runs_dirs {
1870 deny_regexes.insert(escape_sbpl_regex(p));
1871 }
1872 for lit in &deny_regexes {
1873 profile.push_str(&format!(" (regex #\"^{lit}/[^/]*\\.jsonl$\")\n"));
1874 }
1875 profile.push_str(")\n");
1876 profile.push('\n');
1877
1878 let authority_writes = authority_write_denies(inputs);
1887 let mut authority_write_literals = std::collections::BTreeSet::new();
1888 for path in &authority_writes.files {
1889 authority_write_literals.insert(escape_sbpl_literal(path));
1890 }
1891 let mut authority_write_subpaths = std::collections::BTreeSet::new();
1892 for path in &authority_writes.dirs {
1893 authority_write_subpaths.insert(escape_sbpl_literal(path));
1894 }
1895 let mut sealed_regexes = std::collections::BTreeSet::new();
1901 for root in sealed_kranz_dir_roots(inputs) {
1902 sealed_regexes.insert(escape_sbpl_regex(&root));
1903 }
1904 if !authority_write_literals.is_empty()
1905 || !authority_write_subpaths.is_empty()
1906 || !sealed_regexes.is_empty()
1907 {
1908 profile.push_str("(deny file-write*\n");
1909 for lit in &authority_write_subpaths {
1910 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
1911 }
1912 for lit in &authority_write_literals {
1913 profile.push_str(&format!(" (literal \"{lit}\")\n"));
1914 }
1915 for root in &sealed_regexes {
1916 profile.push_str(&format!(" (regex #\"^{root}/[^/]*$\")\n"));
1917 }
1918 profile.push_str(")\n");
1919 profile.push('\n');
1920 }
1921
1922 let git_writes = git_metadata_write_denies(inputs);
1929 let mut git_write_literals = std::collections::BTreeSet::new();
1930 for path in &git_writes.files {
1931 git_write_literals.insert(escape_sbpl_literal(path));
1932 }
1933 let mut git_write_subpaths = std::collections::BTreeSet::new();
1934 for path in &git_writes.dirs {
1935 git_write_subpaths.insert(escape_sbpl_literal(path));
1936 }
1937 if !git_write_literals.is_empty() || !git_write_subpaths.is_empty() {
1938 profile.push_str("(deny file-write*\n");
1939 for lit in &git_write_subpaths {
1940 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
1941 }
1942 for lit in &git_write_literals {
1943 profile.push_str(&format!(" (literal \"{lit}\")\n"));
1944 }
1945 profile.push_str(")\n");
1946 profile.push('\n');
1947 }
1948
1949 let tty_block = tty_deny_block(&operator_tty_paths());
1957 if !tty_block.is_empty() {
1958 profile.push_str(&tty_block);
1959 profile.push('\n');
1960 }
1961
1962 profile.push_str("(deny file-write*\n");
1963 if let Some(missions) = inputs
1966 .mission_dir
1967 .parent()
1968 .filter(|path| path.ends_with("missions"))
1969 {
1970 for root in [missions.to_path_buf(), absolutize(missions)] {
1971 let root = escape_sbpl_regex(&root);
1972 profile.push_str(&format!(
1973 " (regex #\"^{root}/[^/]+/(events\\.jsonl(\\.lock)?|state\\.json(\\.tmp)?|estimate\\.json)$\")\n"
1974 ));
1975 profile.push_str(&format!(" (regex #\"^{root}/[^/]+/control(/|$)\")\n"));
1976 profile.push_str(&format!(
1977 " (regex #\"^{root}/[^/]+/runs/[^/]*\\.jsonl$\")\n"
1978 ));
1979 }
1980 }
1981 profile.push_str(")\n");
1982
1983 let mut pinned_dirs = std::collections::BTreeSet::new();
1987 for path in authority_read_deny_paths(inputs)
1988 .into_iter()
1989 .chain(authority_read_deny_dirs(inputs))
1990 .chain(mission_write_denies(inputs).control_dirs)
1991 {
1992 for parent in path.ancestors().skip(1) {
1993 pinned_dirs.insert(escape_sbpl_literal(parent));
1994 }
1995 }
1996 profile.push_str("(deny file-write-unlink\n");
1997 for path in pinned_dirs {
1998 profile.push_str(&format!(" (literal \"{path}\")\n"));
1999 }
2000 if let Some(missions) = inputs
2001 .mission_dir
2002 .parent()
2003 .filter(|p| p.ends_with("missions"))
2004 {
2005 for path in [missions.to_path_buf(), absolutize(missions)] {
2006 profile.push_str(&format!(
2007 " (regex #\"^{}/[^/]+(/(control|runs))?$\")\n",
2008 escape_sbpl_regex(&path)
2009 ));
2010 }
2011 }
2012 profile.push_str(")\n");
2013
2014 let mut cache_denies = std::collections::BTreeSet::new();
2024 for path in cargo_cache_write_deny_paths() {
2025 cache_denies.insert(escape_sbpl_literal(&path));
2026 }
2027 if !cache_denies.is_empty() {
2028 profile.push_str("(deny file-write*\n");
2029 for lit in &cache_denies {
2030 profile.push_str(&format!(" (subpath \"{lit}\")\n"));
2031 }
2032 profile.push_str(")\n");
2033 }
2034
2035 profile
2036}
2037
2038pub fn bubblewrap_args(
2065 inputs: &SandboxInputs,
2066 binary: &Path,
2067 args: &[String],
2068) -> crate::error::Result<Vec<String>> {
2069 let mut out = vec![
2070 "--die-with-parent".to_string(),
2071 "--ro-bind".to_string(),
2072 "/".to_string(),
2073 "/".to_string(),
2074 "--dev".to_string(),
2075 "/dev".to_string(),
2076 "--proc".to_string(),
2077 "/proc".to_string(),
2078 "--unshare-pid".to_string(),
2109 "--unshare-ipc".to_string(),
2110 "--unshare-uts".to_string(),
2111 "--unshare-cgroup-try".to_string(),
2112 "--new-session".to_string(),
2113 ];
2114 if inputs.enforce == crate::types::SandboxEnforce::FsNet {
2115 out.push("--unshare-net".to_string());
2116 }
2117 for path in write_allowlist(inputs) {
2118 let path = path.display().to_string();
2119 out.push("--bind".to_string());
2120 out.push(path.clone());
2121 out.push(path);
2122 }
2123 if let Some(missions) = inputs
2127 .mission_dir
2128 .parent()
2129 .filter(|path| path.ends_with("missions") && path.is_dir())
2130 {
2131 let missions = absolutize(missions);
2132 let display = missions.display().to_string();
2133 out.extend(["--ro-bind".to_string(), display.clone(), display]);
2134 for private in [&inputs.session_cwd, &inputs.tmpdir] {
2135 let private = absolutize(private);
2136 if private.starts_with(&missions) && private != missions {
2137 let display = private.display().to_string();
2138 out.extend(["--bind".to_string(), display.clone(), display]);
2139 }
2140 }
2141 }
2142 let cache_ro_binds: std::collections::BTreeSet<String> = cargo_cache_write_deny_paths()
2153 .iter()
2154 .filter(|path| path.is_dir())
2155 .map(|path| path.display().to_string())
2156 .collect();
2157 for bind in &cache_ro_binds {
2158 out.push("--ro-bind".to_string());
2159 out.push(bind.clone());
2160 out.push(bind.clone());
2161 }
2162 let masked_files: Vec<PathBuf> = authority_read_deny_paths(inputs)
2184 .iter()
2185 .map(|p| lexical_absolute(p))
2186 .collect();
2187 let masked_dirs: Vec<PathBuf> = authority_read_deny_dirs(inputs)
2188 .iter()
2189 .map(|p| lexical_absolute(p))
2190 .collect();
2191 let is_masked = |path: &Path| -> bool {
2192 let abs = lexical_absolute(path);
2193 masked_files.contains(&abs) || masked_dirs.iter().any(|d| abs.starts_with(d))
2194 };
2195 let git_mount_nodes = git_metadata_mount_nodes(inputs);
2196 for node in &git_mount_nodes {
2197 let node = node.display().to_string();
2198 out.extend(["--bind".to_string(), node.clone(), node]);
2199 }
2200 let authority_writes = authority_write_denies(inputs);
2201 let git_writes = git_metadata_write_denies(inputs);
2202 let write_ro_binds: std::collections::BTreeSet<String> = authority_writes
2203 .files
2204 .iter()
2205 .chain(authority_writes.dirs.iter())
2206 .filter(|path| path.exists())
2207 .chain(
2208 git_writes
2209 .files
2210 .iter()
2211 .filter(|path| path.is_file() && !path.is_symlink()),
2212 )
2213 .chain(git_writes.dirs.iter().filter(|path| path.is_dir()))
2214 .filter(|path| !is_masked(path))
2215 .map(|path| lexical_absolute(path).display().to_string())
2216 .collect();
2217 for bind in &write_ro_binds {
2218 out.push("--ro-bind".to_string());
2219 out.push(bind.clone());
2220 out.push(bind.clone());
2221 }
2222 let write_denies = mission_write_denies(inputs);
2226 for path in &write_denies.files {
2227 match std::fs::symlink_metadata(path) {
2228 Ok(metadata) if metadata.file_type().is_file() => {}
2229 Ok(_) => {
2230 return Err(crate::error::EngineError::InvalidState(format!(
2231 "bwrap mask prep: {} exists and is not a regular file",
2232 path.display()
2233 )))
2234 }
2235 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2236 Err(error) => return Err(error.into()),
2237 }
2238 }
2239 let authority_dirs: Vec<_> = authority_read_deny_dirs(inputs)
2240 .iter()
2241 .map(|path| absolutize(path))
2242 .collect();
2243 let authority_masks = authority_directory_masks(inputs);
2244 for mask in &authority_masks {
2245 let display = mask.path.display().to_string();
2246 out.extend(["--tmpfs".to_string(), display.clone()]);
2247 for path in &mask.visible_entries {
2248 let path = path.display().to_string();
2249 out.extend(["--ro-bind-try".to_string(), path.clone(), path]);
2253 }
2254 for nested in &authority_masks {
2258 if nested.path != mask.path
2259 && nested.path.starts_with(&mask.path)
2260 && !mask
2261 .visible_entries
2262 .iter()
2263 .any(|entry| nested.path.starts_with(entry))
2264 {
2265 out.extend(["--dir".to_string(), nested.path.display().to_string()]);
2266 }
2267 }
2268 out.extend(["--remount-ro".to_string(), display]);
2269 for private in [&inputs.session_cwd, &inputs.tmpdir] {
2272 let private = absolutize(private);
2273 if private != mask.path
2274 && private.starts_with(&mask.path)
2275 && !authority_dirs
2276 .iter()
2277 .any(|denied| private.starts_with(denied))
2278 {
2279 let path = private.display().to_string();
2280 out.extend(["--bind".to_string(), path.clone(), path.clone()]);
2281 for node in git_mount_nodes
2282 .iter()
2283 .filter(|node| node.starts_with(&private))
2284 {
2285 let node = node.display().to_string();
2286 out.extend(["--bind".to_string(), node.clone(), node]);
2287 }
2288 let restored_denies: std::collections::BTreeSet<_> = cache_ro_binds
2293 .iter()
2294 .chain(&write_ro_binds)
2295 .filter_map(|denied| {
2296 let denied_path = Path::new(denied);
2297 if denied_path.starts_with(&private) {
2298 Some(denied)
2299 } else if private.starts_with(denied_path) {
2300 Some(&path)
2301 } else {
2302 None
2303 }
2304 })
2305 .collect();
2306 for denied in restored_denies {
2307 out.extend(["--ro-bind".to_string(), denied.clone(), denied.clone()]);
2308 }
2309 }
2310 }
2311 }
2312 for entry in validator_read_deny_entries(inputs) {
2323 let display = entry.path.display().to_string();
2324 if entry.is_dir {
2325 out.push("--tmpfs".to_string());
2326 out.push(display);
2327 } else {
2328 out.push("--ro-bind".to_string());
2329 out.push("/dev/null".to_string());
2330 out.push(display);
2331 }
2332 }
2333 out.push("--chdir".to_string());
2334 out.push(absolutize(&inputs.session_cwd).display().to_string());
2335 out.push("--".to_string());
2336 out.push(binary.display().to_string());
2337 out.extend(args.iter().cloned());
2338 Ok(out)
2339}
2340
2341pub fn write_profile_file(dir: &Path, profile: &str) -> std::io::Result<PathBuf> {
2343 std::fs::create_dir_all(dir)?;
2344 let path = dir.join(format!("kranz-sandbox-{}.sb", uuid::Uuid::new_v4()));
2345 std::fs::write(&path, profile)?;
2346 Ok(path)
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351 use super::*;
2352
2353 #[cfg(target_os = "macos")]
2354 use std::sync::Mutex;
2355
2356 #[test]
2362 fn expand_tilde_uses_the_platform_home_variable() {
2363 assert_eq!(
2364 expand_tilde("relative/path"),
2365 PathBuf::from("relative/path")
2366 );
2367 assert_eq!(expand_tilde("~notatilde"), PathBuf::from("~notatilde"));
2368
2369 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
2370 .expect("the platform home variable is always set on a real host");
2371 let expanded = expand_tilde("~/cache");
2372 assert_eq!(expanded, PathBuf::from(&home).join("cache"));
2373 assert!(
2374 expanded.is_absolute(),
2375 "an expanded home path must be absolute: {expanded:?}"
2376 );
2377
2378 #[cfg(windows)]
2379 assert_eq!(expand_tilde(r"~\cache"), PathBuf::from(&home).join("cache"));
2380 }
2381
2382 #[cfg(target_os = "macos")]
2383 static SANDBOX_EXEC_TEST_LOCK: Mutex<()> = Mutex::new(());
2384
2385 #[cfg(target_os = "macos")]
2386 fn sandbox_exec_can_apply() -> bool {
2387 let found = std::process::Command::new("which")
2388 .arg("sandbox-exec")
2389 .output()
2390 .map(|o| o.status.success())
2391 .unwrap_or(false);
2392 if !found {
2393 crate::test_capability::skip(
2394 crate::test_capability::capability::SANDBOX_EXEC,
2395 "sandbox-exec not found on this host",
2396 );
2397 return false;
2398 }
2399
2400 let smoke = std::process::Command::new("sandbox-exec")
2401 .arg("-p")
2402 .arg("(version 1)\n(allow default)\n")
2403 .arg("/usr/bin/true")
2404 .output();
2405 match smoke {
2406 Ok(output) if output.status.success() => true,
2407 Ok(output) => {
2408 eprintln!(
2409 "sandbox-exec cannot apply a smoke profile on this host; skipping: {}",
2410 String::from_utf8_lossy(&output.stderr)
2411 );
2412 false
2413 }
2414 Err(e) => {
2415 eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
2416 false
2417 }
2418 }
2419 }
2420
2421 #[cfg(target_os = "linux")]
2422 fn bwrap_can_apply() -> bool {
2423 if !command_available("bwrap") {
2424 crate::test_capability::skip(
2425 crate::test_capability::capability::BWRAP,
2426 "bwrap not found on this host",
2427 );
2428 return false;
2429 }
2430
2431 let smoke = std::process::Command::new("bwrap")
2432 .args([
2433 "--die-with-parent",
2434 "--ro-bind",
2435 "/",
2436 "/",
2437 "--dev",
2438 "/dev",
2439 "--proc",
2440 "/proc",
2441 "--",
2442 "/bin/true",
2443 ])
2444 .output();
2445 match smoke {
2446 Ok(output) if output.status.success() => true,
2447 Ok(output) => {
2448 eprintln!(
2449 "bwrap cannot apply a smoke sandbox on this host; skipping: {}",
2450 String::from_utf8_lossy(&output.stderr)
2451 );
2452 false
2453 }
2454 Err(e) => {
2455 eprintln!("bwrap smoke probe failed; skipping: {e}");
2456 false
2457 }
2458 }
2459 }
2460
2461 fn inputs(
2462 session_cwd: &Path,
2463 mission_dir: &Path,
2464 tmpdir: &Path,
2465 extra: Vec<PathBuf>,
2466 ) -> SandboxInputs {
2467 SandboxInputs {
2468 enforce: crate::types::SandboxEnforce::Fs,
2469 session_cwd: session_cwd.to_path_buf(),
2470 mission_dir: mission_dir.to_path_buf(),
2471 tmpdir: tmpdir.to_path_buf(),
2472 extra_write: extra,
2473 egress: Vec::new(),
2474 validator_read_deny_roots: Vec::new(),
2475 }
2476 }
2477
2478 #[test]
2479 fn sandbox_profile_contains_required_clauses() {
2480 let session = tempfile::tempdir().unwrap();
2481 let mission = tempfile::tempdir().unwrap();
2482 let tmp = tempfile::tempdir().unwrap();
2483 let extra = tempfile::tempdir().unwrap();
2484
2485 let profile = generate_profile(&inputs(
2486 session.path(),
2487 mission.path(),
2488 tmp.path(),
2489 vec![extra.path().to_path_buf()],
2490 ));
2491
2492 assert!(profile.contains("(version 1)"));
2493 assert!(profile.contains("(deny default)"));
2494 assert!(profile.contains("(allow file-read*)"));
2495 assert!(profile.contains("(allow file-write* (literal \"/dev/null\"))"));
2496 assert!(profile.contains("(allow network*)"));
2498 assert!(!profile.contains("(deny network*)"));
2499
2500 let session_abs = absolutize(session.path());
2501 let tmp_abs = absolutize(tmp.path());
2502 let extra_abs = absolutize(extra.path());
2503
2504 for p in [&session_abs, &tmp_abs, &extra_abs] {
2507 let expected = format!("(subpath \"{}\")", escape_sbpl_literal(p));
2508 assert!(
2509 profile.contains(&expected),
2510 "profile missing subpath rule for {:?}:\n{}",
2511 p,
2512 profile
2513 );
2514 }
2515
2516 let mission_abs = absolutize(mission.path());
2520 let mission_rule = format!("(subpath \"{}\")", escape_sbpl_literal(&mission_abs));
2521 assert!(
2522 !profile.contains(&mission_rule),
2523 "profile must not allow writes to the whole mission dir:\n{profile}"
2524 );
2525 }
2526
2527 #[test]
2528 fn sandbox_profile_denies_authority_material_reads() {
2529 let _env = crate::agent_env::EnvTestGuard::engage(&[]);
2530 let repo = tempfile::tempdir().unwrap();
2531 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2532 std::fs::create_dir_all(&mission).unwrap();
2533 let tmp = tempfile::tempdir().unwrap();
2534
2535 let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
2536
2537 assert!(profile.contains("(allow file-read*)"));
2540 assert!(profile.contains("(deny file-read*"));
2541 let kranz_dir = repo.path().join(".kranz");
2542 for name in [
2543 "serve.token",
2544 "serve.read.token",
2545 "config.json",
2546 "domain-terms.local",
2548 ] {
2549 for base in [kranz_dir.clone(), absolutize(&kranz_dir)] {
2550 let expected = format!("(literal \"{}\")", escape_sbpl_literal(&base.join(name)));
2551 assert!(
2552 profile.contains(&expected),
2553 "profile missing read deny for {}:\n{profile}",
2554 base.join(name).display()
2555 );
2556 }
2557 }
2558 assert!(
2562 profile.contains("credentials.toml"),
2563 "profile missing read deny for cargo credentials:\n{profile}"
2564 );
2565 if let Some(global) = crate::paths::global_config() {
2566 let global_dir = global.parent().unwrap();
2567 for operation in ["(deny file-read*", "(deny file-write*"] {
2568 for protected in [&global, global_dir] {
2569 assert!(
2570 profile.split(operation).skip(1).any(|block| {
2571 block
2572 .split("\n)\n")
2573 .next()
2574 .unwrap()
2575 .contains(&escape_sbpl_literal(protected))
2576 }),
2577 "missing {operation} rule for {}",
2578 protected.display()
2579 );
2580 }
2581 }
2582 }
2583 }
2584
2585 #[cfg(target_os = "macos")]
2586 #[test]
2587 fn sandbox_checkout_denies_authority_writes_and_future_sibling_inboxes() {
2588 use std::process::Command;
2589 let root = tempfile::tempdir().unwrap();
2590 let root = root.path().canonicalize().unwrap();
2591 let mission = root.join(".kranz/missions/m-current");
2592 std::fs::create_dir_all(&mission).unwrap();
2593 let config = root.join(".kranz/config.json");
2594 let config_target = root.join("operator-config.json");
2595 std::fs::write(&config_target, "original").unwrap();
2596 std::os::unix::fs::symlink(&config_target, &config).unwrap();
2597 let scratch = root.join("scratch");
2598 std::fs::create_dir(&scratch).unwrap();
2599 let profile = generate_profile(&inputs(&root, &mission, &scratch, vec![]));
2600 let sibling = root.join(".kranz/missions/m-later/control");
2603 std::fs::create_dir_all(&sibling).unwrap();
2604 let run = |script: &str, path: &Path| {
2605 Command::new("sandbox-exec")
2606 .args(["-p", &profile, "/bin/sh", "-c", script, "audit"])
2607 .arg(path)
2608 .output()
2609 .unwrap()
2610 };
2611 let probe = run("exit 0", &root);
2612 if !probe.status.success()
2613 && String::from_utf8_lossy(&probe.stderr).contains("sandbox_apply")
2614 {
2615 eprintln!("SKIP-UNDER-WRAP: nested sandbox unavailable");
2616 return;
2617 }
2618 assert!(probe.status.success(), "{probe:?}");
2619 for protected in [
2620 config.clone(),
2621 config_target.clone(),
2622 sibling.join("forged.json"),
2623 sibling.parent().unwrap().join("events.jsonl"),
2624 ] {
2625 assert!(
2626 !run("printf forged > \"$1\"", &protected).status.success(),
2627 "wrote {}",
2628 protected.display()
2629 );
2630 }
2631 assert_eq!(std::fs::read_to_string(config).unwrap(), "original");
2632 assert!(
2633 !run("cat \"$1\"", &config_target).status.success(),
2634 "the symlink's target must carry the same authority read denial"
2635 );
2636 assert!(!sibling.join("forged.json").exists());
2637 assert!(
2638 !run(
2639 "ln \"$1/.kranz/config.json\" \"$1/authority-hardlink\"",
2640 &root
2641 )
2642 .status
2643 .success(),
2644 "a hard link must not move authority outside the read deny"
2645 );
2646 assert!(
2647 !run("mv \"$1/.kranz\" \"$1/moved-authority\"", &root)
2648 .status
2649 .success(),
2650 "renaming the authority parent must not move it outside its deny rules"
2651 );
2652 assert!(run("printf feature > \"$1\"", &root.join("feature.txt"))
2653 .status
2654 .success());
2655 assert!(run("printf scratch > \"$1\"", &scratch.join("work.txt"))
2656 .status
2657 .success());
2658 }
2659
2660 #[cfg(target_os = "macos")]
2661 #[test]
2662 fn sandbox_global_authority_is_denied_when_created_after_profile() {
2663 if crate::agent_env::isolated_global_home_test(
2664 "sandbox::tests::sandbox_global_authority_is_denied_when_created_after_profile",
2665 ) {
2666 return;
2667 }
2668 let dir = tempfile::tempdir().unwrap();
2669 let root = dir.path().canonicalize().unwrap();
2670 let home = root.join("operator");
2671 let alias = root.join("operator-alias");
2672 std::fs::create_dir(&home).unwrap();
2673 std::os::unix::fs::symlink(&home, &alias).unwrap();
2674 let session = root.join("session");
2675 let mission = session.join(".kranz/missions/m-test");
2676 let scratch = root.join("scratch");
2677 std::fs::create_dir_all(&mission).unwrap();
2678 std::fs::create_dir(&scratch).unwrap();
2679 let profile = {
2680 let _env = crate::agent_env::EnvTestGuard::engage(&[("HOME", alias.to_str().unwrap())]);
2681 generate_profile(&inputs(&session, &mission, &scratch, vec![home.clone()]))
2682 };
2683 let authority = home.join(".kranz/serve/later.token");
2684 std::fs::create_dir_all(authority.parent().unwrap()).unwrap();
2685 std::fs::write(&authority, "fake-authority").unwrap();
2686 let output = std::process::Command::new("sandbox-exec")
2687 .args(["-p", &profile, "/bin/sh", "-c",
2688 "printf witness > \"$1/witness\" || exit 2; if cat \"$2\"; then exit 3; fi; if printf forged > \"$2\"; then exit 4; fi",
2689 "test"])
2690 .arg(&session).arg(&authority).output().unwrap();
2691 if String::from_utf8_lossy(&output.stderr).contains("sandbox_apply") {
2692 eprintln!("SKIP-UNDER-WRAP: nested sandbox unavailable");
2693 return;
2694 }
2695 assert!(output.status.success(), "{output:?}");
2696 assert!(session.join("witness").exists());
2697 assert_eq!(
2698 std::fs::read_to_string(authority).unwrap(),
2699 "fake-authority"
2700 );
2701 }
2702
2703 #[test]
2711 fn composition_audit_effective_egress_extends_never_replaces_the_default_floor() {
2712 let configured = vec![
2713 " crates.io:443 ".to_string(), "api.anthropic.com:443".to_string(), "registry.npmjs.org:443".to_string(),
2716 ];
2717 let out = effective_egress(&configured);
2718 assert_eq!(
2719 out,
2720 vec![
2721 "api.anthropic.com:443".to_string(),
2722 "*.anthropic.com:443".to_string(),
2723 "crates.io:443".to_string(),
2724 "registry.npmjs.org:443".to_string(),
2725 ]
2726 );
2727 assert_eq!(effective_egress(&[]).len(), DEFAULT_EGRESS.len());
2729 }
2730
2731 #[test]
2736 fn composition_audit_extra_write_extends_never_replaces_the_writable_floor() {
2737 let session = tempfile::tempdir().unwrap();
2738 let mission = tempfile::tempdir().unwrap();
2739 let tmp = tempfile::tempdir().unwrap();
2740 let extra = tempfile::tempdir().unwrap();
2741 let inputs = inputs(
2742 session.path(),
2743 mission.path(),
2744 tmp.path(),
2745 vec![extra.path().to_path_buf()],
2746 );
2747 let writable = write_allowlist(&inputs);
2748 for floor in [absolutize(session.path()), absolutize(tmp.path())] {
2749 assert!(
2750 writable.contains(&floor),
2751 "the writable floor {floor:?} must survive any extraWrite list"
2752 );
2753 }
2754 assert!(writable.contains(&absolutize(extra.path())));
2755 }
2756
2757 #[test]
2763 fn composition_audit_explicit_denies_survive_a_covering_extra_write_allow() {
2764 let repo = tempfile::tempdir().unwrap();
2765 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2766 std::fs::create_dir_all(&mission).unwrap();
2767 let tmp = tempfile::tempdir().unwrap();
2768 let profile = generate_profile(&inputs(
2771 repo.path(),
2772 &mission,
2773 tmp.path(),
2774 vec![repo.path().to_path_buf()],
2775 ));
2776 assert!(
2778 profile.contains(&format!(
2779 "(subpath \"{}\")",
2780 escape_sbpl_literal(&absolutize(repo.path()))
2781 )),
2782 "the covering extraWrite allow must be present:\n{profile}"
2783 );
2784 assert!(profile.contains("(deny file-write*"));
2788 for name in ["events.jsonl", "state.json"] {
2789 assert!(
2790 profile.contains(&escape_sbpl_literal(&mission.join(name))),
2791 "the write deny for {name} must survive the covering allow:\n{profile}"
2792 );
2793 }
2794 assert!(
2797 profile.contains(&escape_sbpl_literal(
2798 &repo.path().join(".kranz").join("serve.token")
2799 )),
2800 "the read deny for serve.token must survive the covering allow:\n{profile}"
2801 );
2802 }
2803
2804 #[test]
2805 fn bubblewrap_args_mask_authority_material_with_dev_null() {
2806 let repo = tempfile::tempdir().unwrap();
2807 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2808 std::fs::create_dir_all(&mission).unwrap();
2809 let tmp = tempfile::tempdir().unwrap();
2810 let serve_token = repo.path().join(".kranz").join("serve.token");
2811 std::fs::write(&serve_token, "secret").unwrap();
2812
2813 let args = bubblewrap_args(
2814 &inputs(repo.path(), &mission, tmp.path(), vec![]),
2815 Path::new("/usr/bin/claude"),
2816 &[],
2817 )
2818 .unwrap();
2819 let joined = args.join(" ");
2820
2821 let expected = format!(
2822 "--tmpfs {}",
2823 absolutize(serve_token.parent().unwrap()).display()
2824 );
2825 assert!(
2826 joined.contains(&expected),
2827 "missing private authority directory: {args:?}"
2828 );
2829 assert!(!joined.contains("serve.token"));
2831 assert!(
2832 !joined.contains("serve.read.token"),
2833 "future authority files must not get a host bind: {args:?}"
2834 );
2835 }
2836
2837 #[test]
2838 fn sandbox_profile_denies_mission_metadata_writes() {
2839 let repo = tempfile::tempdir().unwrap();
2840 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2841 std::fs::create_dir_all(&mission).unwrap();
2842 let scratch = tempfile::tempdir().unwrap();
2843
2844 let profile = generate_profile(&inputs(repo.path(), &mission, scratch.path(), vec![]));
2849
2850 assert!(
2851 profile.contains("(deny file-write*"),
2852 "missing write deny block:\n{profile}"
2853 );
2854 for name in MISSION_METADATA_FILES {
2855 for base in [mission.clone(), absolutize(&mission)] {
2856 let expected = format!("(literal \"{}\")", escape_sbpl_literal(&base.join(name)));
2857 assert!(
2858 profile.contains(&expected),
2859 "profile missing write deny for {}:\n{profile}",
2860 base.join(name).display()
2861 );
2862 }
2863 }
2864 for base in [mission.clone(), absolutize(&mission)] {
2865 let control = format!(
2866 "(subpath \"{}\")",
2867 escape_sbpl_literal(&base.join("control"))
2868 );
2869 assert!(
2870 profile.contains(&control),
2871 "profile missing control/ write deny:\n{profile}"
2872 );
2873 let runs = format!(
2874 "(regex #\"^{}/[^/]*\\.jsonl$\")",
2875 escape_sbpl_regex(&base.join("runs"))
2876 );
2877 assert!(
2878 profile.contains(&runs),
2879 "profile missing transcript write deny:\n{profile}"
2880 );
2881 }
2882 let mission_rule = format!(
2884 "(subpath \"{}\")",
2885 escape_sbpl_literal(&absolutize(&mission))
2886 );
2887 assert!(
2888 !profile.contains(&mission_rule),
2889 "the whole mission dir must not be writable:\n{profile}"
2890 );
2891 }
2892
2893 #[test]
2900 fn cache_write_deny_profile_denies_real_cache_dirs_precisely() {
2901 let cargo = tempfile::tempdir().unwrap();
2902 std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
2903 std::fs::create_dir_all(cargo.path().join("git")).unwrap();
2904 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2905 "CARGO_HOME",
2906 cargo.path().to_str().expect("utf-8 temp path"),
2907 )]);
2908 let session = tempfile::tempdir().unwrap();
2909 let mission = tempfile::tempdir().unwrap();
2910 let scratch = tempfile::tempdir().unwrap();
2911
2912 let profile = generate_profile(&inputs(
2913 session.path(),
2914 mission.path(),
2915 scratch.path(),
2916 vec![],
2917 ));
2918 for base in [cargo.path().to_path_buf(), absolutize(cargo.path())] {
2919 for name in ["registry", "git"] {
2920 let expected = format!("(subpath \"{}\")", escape_sbpl_literal(&base.join(name)));
2921 assert!(
2922 profile.contains(&expected),
2923 "profile missing cache write deny for {}:\n{profile}",
2924 base.join(name).display()
2925 );
2926 }
2927 }
2928 for base in [cargo.path().to_path_buf(), absolutize(cargo.path())] {
2932 let whole_home = format!("(subpath \"{}\")", escape_sbpl_literal(&base));
2933 assert!(
2934 !profile.contains(&whole_home),
2935 "the deny must be precise to the cache dirs, not the whole cargo home:\n{profile}"
2936 );
2937 }
2938 }
2939
2940 #[test]
2945 fn cache_write_deny_bwrap_stacks_ro_binds_over_real_cache() {
2946 let cargo = tempfile::tempdir().unwrap();
2947 std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
2948 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2950 "CARGO_HOME",
2951 cargo.path().to_str().expect("utf-8 temp path"),
2952 )]);
2953 let session = tempfile::tempdir().unwrap();
2954 let mission = tempfile::tempdir().unwrap();
2955 let scratch = tempfile::tempdir().unwrap();
2956
2957 let args = bubblewrap_args(
2958 &inputs(session.path(), mission.path(), scratch.path(), vec![]),
2959 Path::new("/usr/bin/claude"),
2960 &[],
2961 )
2962 .unwrap();
2963 let joined = args.join(" ");
2964
2965 let registry = absolutize(&cargo.path().join("registry"));
2966 let expected = format!("--ro-bind {0} {0}", registry.display());
2967 assert!(
2968 joined.contains(&expected),
2969 "missing stacked ro-bind for the real registry cache: {args:?}"
2970 );
2971 let git_cache = cargo.path().join("git");
2972 assert!(
2973 !joined.contains(&git_cache.display().to_string()),
2974 "an absent cache dir must not be bound: {args:?}"
2975 );
2976 let last_rw = args
2979 .iter()
2980 .rposition(|arg| arg == "--bind")
2981 .expect("the writable roots are rw-bound");
2982 let registry_arg = registry.display().to_string();
2983 let cache_pos = args
2984 .windows(3)
2985 .position(|w| w[0] == "--ro-bind" && w[1] == registry_arg && w[2] == registry_arg)
2986 .expect("the cache ro-bind pair exists");
2987 assert!(
2988 cache_pos > last_rw,
2989 "the cache ro-bind must stack after the rw binds: {args:?}"
2990 );
2991 }
2992
2993 #[test]
2994 fn sandbox_profile_keeps_sibling_temp_neighbors_unwritable() {
2995 let root = tempfile::tempdir().unwrap();
3001 let session = root.path().join("kranz-wt-aaa-m1-f-1-1");
3002 let scratch = root.path().join("kranz-worker-home-sess-1");
3003 let sibling = root.path().join("kranz-wt-bbb-m2-_integration");
3004 let sibling_scratch = root.path().join("kranz-worker-home-sess-2");
3005 for d in [&session, &scratch, &sibling, &sibling_scratch] {
3006 std::fs::create_dir_all(d).unwrap();
3007 }
3008 let mission = tempfile::tempdir().unwrap();
3009
3010 let profile = generate_profile(&inputs(&session, mission.path(), &scratch, vec![]));
3011
3012 for allowed in [&session, &scratch] {
3013 let expected = format!(
3014 "(subpath \"{}\")",
3015 escape_sbpl_literal(&absolutize(allowed))
3016 );
3017 assert!(
3018 profile.contains(&expected),
3019 "profile missing allow for {}:\n{profile}",
3020 allowed.display()
3021 );
3022 }
3023 for denied in [&sibling, &sibling_scratch, &root.path().to_path_buf()] {
3024 let rule = format!("(subpath \"{}\")", escape_sbpl_literal(&absolutize(denied)));
3025 assert!(
3026 !profile.contains(&rule),
3027 "{} must not be writable:\n{profile}",
3028 denied.display()
3029 );
3030 }
3031 }
3032
3033 #[test]
3034 fn bubblewrap_args_mask_mission_metadata() {
3035 let repo = tempfile::tempdir().unwrap();
3036 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3037 let runs = mission.join("runs");
3038 std::fs::create_dir_all(&runs).unwrap();
3039 let scratch = tempfile::tempdir().unwrap();
3040 let events = mission.join("events.jsonl");
3042 let state = mission.join("state.json");
3043 let transcript = runs.join("run-1.jsonl");
3044 let denials = runs.join("egress-denials.jsonl");
3045 for f in [&events, &state, &transcript, &denials] {
3046 std::fs::write(f, "engine").unwrap();
3047 }
3048 let control = mission.join("control");
3049 std::fs::create_dir_all(&control).unwrap();
3050 let contract_home = runs.join("contract-home");
3053 std::fs::create_dir_all(&contract_home).unwrap();
3054 let scratch_jsonl = contract_home.join("notes.jsonl");
3055 std::fs::write(&scratch_jsonl, "session").unwrap();
3056
3057 let args = bubblewrap_args(
3058 &inputs(repo.path(), &mission, scratch.path(), vec![]),
3059 Path::new("/usr/bin/claude"),
3060 &[],
3061 )
3062 .unwrap();
3063 let joined = args.join(" ");
3064
3065 for path in [&events, &state, &mission.join("runs")] {
3066 let path = absolutize(path).display().to_string();
3067 assert!(
3068 joined.contains(&format!("--ro-bind-try {path} {path}")),
3069 "metadata must remain read-only: {args:?}"
3070 );
3071 }
3072 let mission_abs = absolutize(&mission).display().to_string();
3073 assert!(args
3074 .windows(2)
3075 .any(|pair| pair[0] == "--tmpfs" && pair[1] == mission_abs));
3076 assert!(
3077 !args.windows(3).any(|part| {
3078 matches!(part[0].as_str(), "--ro-bind" | "--ro-bind-try")
3079 && part[1] == absolutize(&control).display().to_string()
3080 && part[1] == part[2]
3081 }),
3082 "the control inbox must not be rebound into the private mission directory"
3083 );
3084 assert!(
3085 !mission.join("state.json.tmp").exists(),
3086 "argv construction must not create metadata"
3087 );
3088 assert!(
3091 !joined.contains("estimate.json"),
3092 "absent metadata files must not be masked: {args:?}"
3093 );
3094 let mission_abs = absolutize(&mission);
3097 assert!(
3098 !joined.contains(&format!("--bind {0} {0}", mission_abs.display())),
3099 "mission dir must not be rw-bound: {args:?}"
3100 );
3101 assert!(
3102 !joined.contains(&scratch_jsonl.display().to_string()),
3103 "runs/ subdir scratch files must not be masked: {args:?}"
3104 );
3105 }
3106
3107 #[cfg(unix)]
3108 #[test]
3109 fn bubblewrap_mask_prep_rejects_preexisting_state_tmp_symlink() {
3110 use std::os::unix::fs::symlink;
3111 let repo = tempfile::tempdir().unwrap();
3112 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3113 std::fs::create_dir_all(mission.join("runs")).unwrap();
3114 let target_dir = tempfile::tempdir().unwrap();
3115 let target = target_dir.path().join("outside");
3116 std::fs::write(&target, "unchanged").unwrap();
3117 symlink(&target, mission.join("state.json.tmp")).unwrap();
3118 let scratch = tempfile::tempdir().unwrap();
3119
3120 let error = bubblewrap_args(
3121 &inputs(repo.path(), &mission, scratch.path(), vec![]),
3122 Path::new("/usr/bin/claude"),
3123 &[],
3124 )
3125 .expect_err("a symlink cannot become a bwrap mask mount point");
3126
3127 assert!(error.to_string().contains("not a regular file"), "{error}");
3128 assert_eq!(std::fs::read_to_string(target).unwrap(), "unchanged");
3129 assert!(
3130 std::fs::symlink_metadata(mission.join("state.json.tmp"))
3131 .unwrap()
3132 .file_type()
3133 .is_symlink(),
3134 "mask preparation must not replace or follow the hostile leaf"
3135 );
3136 }
3137
3138 #[test]
3139 fn sandbox_profile_excludes_paths_outside_allowlist() {
3140 let session = tempfile::tempdir().unwrap();
3141 let mission = tempfile::tempdir().unwrap();
3142 let tmp = tempfile::tempdir().unwrap();
3143 let outsider = tempfile::tempdir().unwrap();
3144
3145 let profile = generate_profile(&inputs(session.path(), mission.path(), tmp.path(), vec![]));
3146
3147 let outsider_abs = absolutize(outsider.path());
3148 let forbidden = format!("(subpath \"{}\")", escape_sbpl_literal(&outsider_abs));
3149 assert!(
3150 !profile.contains(&forbidden),
3151 "profile unexpectedly allows write to path outside the allowlist"
3152 );
3153 }
3154
3155 #[test]
3156 fn sandbox_profile_fs_net_restricts_egress_to_loopback() {
3157 let session = tempfile::tempdir().unwrap();
3158 let mission = tempfile::tempdir().unwrap();
3159 let tmp = tempfile::tempdir().unwrap();
3160 let mut inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
3161 inputs.enforce = crate::types::SandboxEnforce::FsNet;
3162 inputs.egress = vec!["crates.io:443".into(), "api.anthropic.com:443".into()];
3163
3164 let profile = generate_profile(&inputs);
3165
3166 assert!(!profile.contains("(allow network*)"));
3170 assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:*\"))"));
3171 assert!(
3172 !profile.contains("crates.io") && !profile.contains("anthropic.com"),
3173 "no per-host egress rules in the profile:\n{profile}"
3174 );
3175 }
3176
3177 #[test]
3178 fn bubblewrap_args_bind_write_roots_and_unshare_network_for_fs_net() {
3179 let session = tempfile::tempdir().unwrap();
3180 let mission = tempfile::tempdir().unwrap();
3181 let tmp = tempfile::tempdir().unwrap();
3182 let extra = tempfile::tempdir().unwrap();
3183 let mut inputs = inputs(
3184 session.path(),
3185 mission.path(),
3186 tmp.path(),
3187 vec![extra.path().to_path_buf()],
3188 );
3189 inputs.enforce = crate::types::SandboxEnforce::FsNet;
3190
3191 let args =
3192 bubblewrap_args(&inputs, Path::new("/usr/bin/claude"), &["--print".into()]).unwrap();
3193 let joined = args.join(" ");
3194
3195 assert!(args.contains(&"--unshare-net".to_string()));
3196 for path in [
3197 absolutize(session.path()),
3198 absolutize(tmp.path()),
3199 absolutize(extra.path()),
3200 ] {
3201 assert!(
3202 joined.contains(&format!("--bind {0} {0}", path.display())),
3203 "bubblewrap args missing bind for {}: {args:?}",
3204 path.display()
3205 );
3206 }
3207 let mission_abs = absolutize(mission.path());
3210 assert!(
3211 !joined.contains(&format!("--bind {0} {0}", mission_abs.display())),
3212 "bubblewrap args must not rw-bind the mission dir: {args:?}"
3213 );
3214 assert!(joined.contains("--ro-bind / /"));
3215 assert!(joined.ends_with("/usr/bin/claude --print"));
3216 }
3217
3218 #[test]
3226 fn bubblewrap_args_unshare_every_namespace_on_both_tiers() {
3227 let session = tempfile::tempdir().unwrap();
3228 let mission = tempfile::tempdir().unwrap();
3229 let tmp = tempfile::tempdir().unwrap();
3230
3231 for enforce in [
3232 crate::types::SandboxEnforce::Fs,
3233 crate::types::SandboxEnforce::FsNet,
3234 ] {
3235 let mut inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
3236 inputs.enforce = enforce;
3237 let args = bubblewrap_args(&inputs, Path::new("/usr/bin/claude"), &[]).unwrap();
3238
3239 for flag in [
3240 "--unshare-pid",
3241 "--unshare-ipc",
3242 "--unshare-uts",
3243 "--unshare-cgroup-try",
3244 "--new-session",
3245 "--die-with-parent",
3246 ] {
3247 assert!(
3248 args.contains(&flag.to_string()),
3249 "{enforce:?} argv missing {flag}: {args:?}"
3250 );
3251 }
3252 assert!(
3258 !args.contains(&"--unshare-cgroup".to_string()),
3259 "the non-try cgroup unshare must never be emitted: {args:?}"
3260 );
3261 assert_eq!(
3262 args.contains(&"--unshare-net".to_string()),
3263 enforce == crate::types::SandboxEnforce::FsNet,
3264 "--unshare-net is the one tier-gated namespace: {args:?}"
3265 );
3266 }
3267 }
3268
3269 fn authority_write_fixture() -> (tempfile::TempDir, PathBuf) {
3273 let repo = tempfile::tempdir().unwrap();
3274 let kranz = repo.path().join(".kranz");
3275 let mission = kranz.join("missions").join("m-x");
3276 std::fs::create_dir_all(mission.join("runs").join("scratch")).unwrap();
3277 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
3278 for name in ["queue", "tickets", "lessons", "hook-status"] {
3279 std::fs::create_dir_all(kranz.join(name)).unwrap();
3280 }
3281 for name in ["config.json", "serve.token", "serve.read.token"] {
3282 std::fs::write(kranz.join(name), "secret").unwrap();
3283 }
3284 std::fs::create_dir_all(repo.path().join(".git").join("hooks")).unwrap();
3285 std::fs::create_dir_all(repo.path().join(".git").join("info")).unwrap();
3286 std::fs::write(repo.path().join(".git").join("config"), "[core]\n").unwrap();
3287 (repo, mission)
3288 }
3289
3290 #[test]
3296 fn sandbox_profile_denies_authority_material_writes() {
3297 let (repo, mission) = authority_write_fixture();
3298 let tmp = tempfile::tempdir().unwrap();
3299 let kranz = repo.path().join(".kranz");
3300
3301 let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
3303
3304 for name in ["config.json", "serve.token", "serve.read.token"] {
3305 let expected = format!(
3306 "(literal \"{}\")",
3307 escape_sbpl_literal(&absolutize(&kranz.join(name)))
3308 );
3309 assert!(
3310 profile.contains(&expected),
3311 "profile missing write deny for .kranz/{name}:\n{profile}"
3312 );
3313 }
3314 for name in ["queue", "tickets", "lessons", "hook-status"] {
3315 let expected = format!(
3316 "(subpath \"{}\")",
3317 escape_sbpl_literal(&absolutize(&kranz.join(name)))
3318 );
3319 assert!(
3320 profile.contains(&expected),
3321 "profile missing write deny for .kranz/{name}/:\n{profile}"
3322 );
3323 }
3324 let other = absolutize(&kranz.join("missions").join("m-other"));
3328 assert!(
3329 profile.contains(&format!("(subpath \"{}\")", escape_sbpl_literal(&other))),
3330 "profile missing write deny for the sibling mission dir:\n{profile}"
3331 );
3332 let own = absolutize(&mission);
3333 assert!(
3334 !profile.contains(&format!("(subpath \"{}\")\n", escape_sbpl_literal(&own))),
3335 "the session's own mission dir must not be denied wholesale:\n{profile}"
3336 );
3337 for root in [absolutize(&kranz), absolutize(&kranz.join("missions"))] {
3340 let expected = format!("(regex #\"^{}/[^/]*$\")", escape_sbpl_regex(&root));
3341 assert!(
3342 profile.contains(&expected),
3343 "profile missing the sealing regex for {}:\n{profile}",
3344 root.display()
3345 );
3346 }
3347 }
3348
3349 #[test]
3355 fn global_key_dir_is_read_and_write_denied() {
3356 let global = crate::paths::global_kranz_dir().expect("a global kranz dir resolves");
3361 let (repo, mission) = authority_write_fixture();
3362 let tmp = tempfile::tempdir().unwrap();
3363 let inputs = inputs(repo.path(), &mission, tmp.path(), vec![]);
3364
3365 for store in ["keys", "seals"] {
3366 let dir = global.join(store);
3367 assert!(
3368 authority_read_deny_dirs(&inputs).contains(&dir),
3369 "the global {store} dir must be read-denied"
3370 );
3371 assert!(
3372 authority_write_denies(&inputs).dirs.contains(&dir),
3373 "the global {store} dir must be write-denied"
3374 );
3375 let profile = generate_profile(&inputs);
3376 let expected = format!("(subpath \"{}\")", escape_sbpl_literal(&dir));
3377 assert!(
3378 profile.matches(&expected).count() >= 2,
3379 "the {store} dir belongs in BOTH the read-deny and the write-deny block:\n{profile}"
3380 );
3381 }
3382 }
3383
3384 #[test]
3389 fn authority_write_denies_refuse_a_noncanonical_mission_layout() {
3390 let session = tempfile::tempdir().unwrap();
3391 let mission = tempfile::tempdir().unwrap();
3392 let tmp = tempfile::tempdir().unwrap();
3393 let inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
3394
3395 assert!(
3396 sealed_kranz_dir_roots(&inputs).is_empty(),
3397 "a non-canonical mission dir must seal nothing"
3398 );
3399 let denies = authority_write_denies(&inputs);
3400 let temp_root = absolutize(&std::env::temp_dir());
3401 assert!(
3402 !denies.dirs.iter().any(|d| d == &temp_root),
3403 "the sweep must never reach the system temp root: {:?}",
3404 denies.dirs
3405 );
3406 }
3407
3408 #[test]
3414 fn sandbox_profile_denies_git_config_and_hook_writes_but_not_the_index() {
3415 let (repo, mission) = authority_write_fixture();
3416 let tmp = tempfile::tempdir().unwrap();
3417
3418 let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
3419
3420 let git = absolutize(&repo.path().join(".git"));
3421 for dir in ["hooks", "info"] {
3422 let expected = format!("(subpath \"{}\")", escape_sbpl_literal(&git.join(dir)));
3423 assert!(
3424 profile.contains(&expected),
3425 "profile missing write deny for .git/{dir}/:\n{profile}"
3426 );
3427 }
3428 for file in ["config", "config.worktree"] {
3429 let expected = format!("(literal \"{}\")", escape_sbpl_literal(&git.join(file)));
3430 assert!(
3431 profile.contains(&expected),
3432 "profile missing write deny for .git/{file}:\n{profile}"
3433 );
3434 }
3435 assert!(
3438 profile.contains(&format!("(literal \"{}\")", escape_sbpl_literal(&git))),
3439 "profile missing write deny for the .git node itself:\n{profile}"
3440 );
3441 for open in ["index", "objects", "refs"] {
3443 let denied = format!("(subpath \"{}\")", escape_sbpl_literal(&git.join(open)));
3444 assert!(
3445 !profile.contains(&denied),
3446 ".git/{open} must stay writable — the worker commits:\n{profile}"
3447 );
3448 }
3449 }
3450
3451 #[test]
3458 fn git_metadata_write_denies_cover_the_submodule_config_and_hook_surface() {
3459 let (repo, mission) = authority_write_fixture();
3460 let tmp = tempfile::tempdir().unwrap();
3461 let inputs = inputs(repo.path(), &mission, tmp.path(), vec![]);
3462
3463 let modules = absolutize(&repo.path().join(".git").join("modules"));
3464 assert!(
3465 git_metadata_write_denies(&inputs).dirs.contains(&modules),
3466 "the .git/modules subtree must be write-denied: {:?}",
3467 git_metadata_write_denies(&inputs).dirs
3468 );
3469 let profile = generate_profile(&inputs);
3470 assert!(
3471 profile.contains(&format!("(subpath \"{}\")", escape_sbpl_literal(&modules))),
3472 "profile missing write deny for .git/modules/:\n{profile}"
3473 );
3474 }
3475
3476 #[test]
3485 fn bubblewrap_args_never_self_bind_a_masked_authority_path() {
3486 let (repo, mission) = authority_write_fixture();
3487 let tmp = tempfile::tempdir().unwrap();
3488 let inputs = inputs(repo.path(), &mission, tmp.path(), vec![]);
3489 let args = bubblewrap_args(&inputs, Path::new("/bin/true"), &[]).unwrap();
3490 let masked: Vec<String> = authority_read_deny_paths(&inputs)
3491 .iter()
3492 .map(|path| absolutize(path).display().to_string())
3493 .collect();
3494 let kranz = absolutize(&repo.path().join(".kranz"))
3495 .display()
3496 .to_string();
3497 assert!(args
3498 .windows(2)
3499 .any(|pair| pair[0] == "--tmpfs" && pair[1] == kranz));
3500 let mut i = 0;
3501 while i + 2 < args.len() {
3502 if matches!(args[i].as_str(), "--ro-bind" | "--ro-bind-try")
3503 && args[i + 1] == args[i + 2]
3504 {
3505 assert!(
3506 !masked.contains(&args[i + 2]),
3507 "{} is masked and must not be re-bound over itself",
3508 args[i + 2]
3509 );
3510 }
3511 i += 1;
3512 }
3513 }
3514
3515 #[test]
3516 fn bubblewrap_args_ro_bind_authority_and_git_write_denies() {
3517 let (repo, mission) = authority_write_fixture();
3518 let tmp = tempfile::tempdir().unwrap();
3519 let kranz = repo.path().join(".kranz");
3520
3521 let args = bubblewrap_args(
3522 &inputs(repo.path(), &mission, tmp.path(), vec![]),
3523 Path::new("/usr/bin/claude"),
3524 &[],
3525 )
3526 .unwrap();
3527 let joined = args.join(" ");
3528
3529 for path in [
3530 kranz.join("queue"),
3531 kranz.join("tickets"),
3532 kranz.join("lessons"),
3533 kranz.join("missions").join("m-other"),
3534 repo.path().join(".git").join("hooks"),
3535 repo.path().join(".git").join("info"),
3536 repo.path().join(".git").join("config"),
3537 ] {
3538 let expected = format!("--ro-bind {0} {0}", lexical_absolute(&path).display());
3539 assert!(
3540 joined.contains(&expected),
3541 "bwrap argv missing the write-closing ro-bind for {}: {args:?}",
3542 path.display()
3543 );
3544 }
3545 let git = lexical_absolute(&repo.path().join(".git"));
3546 assert!(
3547 !joined.contains(&format!("--ro-bind {0} {0}", git.display())),
3548 "the .git DIRECTORY must never be ro-bound whole — the worker commits: {args:?}"
3549 );
3550 }
3551
3552 #[test]
3559 fn tty_deny_block_denies_ioctl_on_the_named_terminal_and_nothing_when_absent() {
3560 let block = tty_deny_block(&[
3561 PathBuf::from("/dev/ttys003"),
3562 PathBuf::from("/dev/ttys003"),
3563 PathBuf::from("/dev/ttys001"),
3564 ]);
3565 assert!(block.starts_with("(deny file-read* file-write* file-ioctl\n"));
3566 assert!(block.contains("(literal \"/dev/ttys003\")"), "{block}");
3567 assert!(block.contains("(literal \"/dev/ttys001\")"), "{block}");
3568 assert_eq!(
3569 block.matches("/dev/ttys003").count(),
3570 1,
3571 "duplicate fds must collapse to one literal:\n{block}"
3572 );
3573 assert!(
3574 tty_deny_block(&[]).is_empty(),
3575 "no controlling terminal means no deny block"
3576 );
3577 }
3578
3579 #[test]
3580 fn sandbox_profile_write_profile_file_roundtrip() {
3581 let dir = tempfile::tempdir().unwrap();
3582 let profile = "(version 1)\n(deny default)\n";
3583 let path = write_profile_file(dir.path(), profile).unwrap();
3584 assert_eq!(std::fs::read_to_string(&path).unwrap(), profile);
3585 assert!(path.starts_with(dir.path()));
3586 }
3587
3588 #[test]
3589 fn sandbox_platform_support_matrix() {
3590 use crate::types::SandboxEnforce;
3591
3592 assert_eq!(
3593 platform_support(SandboxEnforce::Off, "macos"),
3594 SandboxDecision::Off
3595 );
3596 assert_eq!(
3597 platform_support(SandboxEnforce::Fs, "macos"),
3598 SandboxDecision::Enforce(SandboxBackend::Seatbelt)
3599 );
3600 assert_eq!(
3601 platform_support(SandboxEnforce::Fs, "linux"),
3602 SandboxDecision::Enforce(SandboxBackend::Bubblewrap)
3603 );
3604 assert_eq!(
3605 platform_support(SandboxEnforce::FsNet, "macos"),
3606 SandboxDecision::Enforce(SandboxBackend::Seatbelt)
3607 );
3608 assert_eq!(
3609 platform_support(SandboxEnforce::FsNet, "linux"),
3610 SandboxDecision::Enforce(SandboxBackend::Bubblewrap)
3611 );
3612 assert_eq!(
3613 platform_support(SandboxEnforce::Fs, "windows"),
3614 SandboxDecision::Enforce(SandboxBackend::AppContainer)
3615 );
3616 assert_eq!(
3617 platform_support(SandboxEnforce::FsNet, "windows"),
3618 SandboxDecision::Enforce(SandboxBackend::AppContainer)
3619 );
3620 assert_eq!(
3621 platform_support(SandboxEnforce::Off, "linux"),
3622 SandboxDecision::Off
3623 );
3624 }
3625
3626 #[test]
3627 fn sandbox_resolve_off_yields_none() {
3628 let cfg = crate::types::SandboxConfig {
3629 enforce: crate::types::SandboxEnforce::Off,
3630 provider: crate::types::SandboxProvider::Process,
3631 image: None,
3632 extra_write: vec![],
3633 egress: vec![],
3634 };
3635 let session = tempfile::tempdir().unwrap();
3636 let mission = tempfile::tempdir().unwrap();
3637
3638 let (resolved, warn) = resolve_for_session(&cfg, session.path(), mission.path());
3639 assert!(resolved.is_none());
3640 assert!(warn.is_none());
3641 }
3642
3643 #[test]
3644 fn sandbox_resolve_linux_requires_bwrap() {
3645 let cfg = crate::types::SandboxConfig {
3646 enforce: crate::types::SandboxEnforce::Fs,
3647 provider: crate::types::SandboxProvider::Process,
3648 image: None,
3649 extra_write: vec![],
3650 egress: vec![],
3651 };
3652 let session = tempfile::tempdir().unwrap();
3653 let mission = tempfile::tempdir().unwrap();
3654
3655 let (resolved, warn) = resolve_for_session_target(
3656 &cfg,
3657 session.path(),
3658 mission.path(),
3659 "linux",
3660 false,
3661 None,
3662 None,
3663 );
3664 assert!(resolved.is_none());
3665 assert!(
3666 warn.unwrap().contains("bwrap"),
3667 "missing-bwrap warning should name bwrap"
3668 );
3669
3670 let (resolved, warn) = resolve_for_session_target(
3671 &cfg,
3672 session.path(),
3673 mission.path(),
3674 "linux",
3675 true,
3676 None,
3677 None,
3678 );
3679 assert!(warn.is_none());
3680 assert_eq!(
3681 resolved.expect("bwrap present").backend,
3682 SandboxBackend::Bubblewrap
3683 );
3684 }
3685
3686 fn container_cfg(
3687 enforce: crate::types::SandboxEnforce,
3688 egress: Vec<String>,
3689 ) -> crate::types::SandboxConfig {
3690 crate::types::SandboxConfig {
3691 enforce,
3692 provider: crate::types::SandboxProvider::Container,
3693 image: None,
3694 extra_write: vec![],
3695 egress,
3696 }
3697 }
3698
3699 #[test]
3700 fn container_provider_off_stays_unsandboxed() {
3701 let cfg = container_cfg(crate::types::SandboxEnforce::Off, vec![]);
3702 let session = tempfile::tempdir().unwrap();
3703 let mission = tempfile::tempdir().unwrap();
3704
3705 let (resolved, warn) = resolve_for_session_target(
3706 &cfg,
3707 session.path(),
3708 mission.path(),
3709 "macos",
3710 false,
3711 None,
3712 None,
3713 );
3714 assert!(resolved.is_none());
3715 assert!(warn.is_none());
3716 }
3717
3718 #[test]
3719 fn container_provider_on_macos_resolves_only_against_a_mount_proof() {
3720 use crate::sandbox_container::{ContainerRuntime, MountProof};
3721 let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3722 let session = tempfile::tempdir().unwrap();
3723 let mission = tempfile::tempdir().unwrap();
3724 let resolve = |proof: Option<MountProof>| {
3725 resolve_for_session_target(
3726 &cfg,
3727 session.path(),
3728 mission.path(),
3729 "macos",
3730 false,
3731 Some(ContainerRuntime::Docker),
3732 proof,
3733 )
3734 };
3735
3736 let (resolved, warn) = resolve(Some(MountProof::Proven));
3738 assert!(resolved.is_some(), "a proven mount must resolve: {warn:?}");
3739
3740 let (resolved, warn) = resolve(Some(MountProof::Failed(
3743 "docker accepted a bind mount of /var/folders/x and shared nothing".to_string(),
3744 )));
3745 assert!(resolved.is_none());
3746 let warn = warn.expect("a failed proof must refuse loudly");
3747 assert!(warn.contains("/var/folders/x"), "{warn}");
3748 assert!(warn.contains("shared nothing"), "{warn}");
3749
3750 let (resolved, warn) = resolve(None);
3752 assert!(resolved.is_none());
3753 let warn = warn.expect("an unproven host must refuse");
3754 assert!(warn.contains("requires a bind-mount proof"), "{warn}");
3755 }
3756
3757 #[test]
3758 fn container_provider_on_windows_refuses_even_a_proven_mount() {
3759 use crate::sandbox_container::{ContainerRuntime, MountProof};
3760 let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3761 let session = tempfile::tempdir().unwrap();
3762 let mission = tempfile::tempdir().unwrap();
3763
3764 let (resolved, warn) = resolve_for_session_target(
3767 &cfg,
3768 session.path(),
3769 mission.path(),
3770 "windows",
3771 false,
3772 Some(ContainerRuntime::Docker),
3773 Some(MountProof::Proven),
3774 );
3775 assert!(resolved.is_none());
3776 let warn = warn.expect("windows must refuse");
3777 assert!(
3778 warn.contains("not supported on target_os=windows"),
3779 "{warn}"
3780 );
3781 assert!(warn.contains("POSIX guest paths"), "{warn}");
3782 }
3783
3784 #[test]
3785 fn container_provider_without_runtime_fails_closed() {
3786 let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3787 let session = tempfile::tempdir().unwrap();
3788 let mission = tempfile::tempdir().unwrap();
3789
3790 let (resolved, warn) = resolve_for_session_target(
3791 &cfg,
3792 session.path(),
3793 mission.path(),
3794 "linux",
3795 false,
3796 None,
3797 None,
3798 );
3799 assert!(resolved.is_none());
3800 let warn = warn.expect("missing runtime must produce a warning");
3801 assert!(warn.contains("provider:container"), "{warn}");
3802 assert!(warn.contains("docker/podman/nerdctl/container"), "{warn}");
3803 assert!(warn.contains("refusing to run unsandboxed"), "{warn}");
3804 }
3805
3806 #[test]
3807 fn macos_enforced_container_provider_fails_closed_to_native_process_guidance() {
3808 let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3809 let session = tempfile::tempdir().unwrap();
3810 let mission = tempfile::tempdir().unwrap();
3811
3812 let (resolved, warning) = resolve_for_session_target(
3813 &cfg,
3814 session.path(),
3815 mission.path(),
3816 "macos",
3817 false,
3818 Some(crate::sandbox_container::ContainerRuntime::Docker),
3819 None,
3820 );
3821 assert!(resolved.is_none());
3822 let warning = warning.expect("an unproved macOS container must refuse");
3823 assert!(warning.contains("requires a bind-mount proof"), "{warning}");
3827 assert!(
3828 warning.contains("sandbox.provider=\"process\""),
3829 "{warning}"
3830 );
3831 assert!(warning.contains("native host containment"), "{warning}");
3832 }
3833
3834 #[test]
3839 fn windows_enforced_session_process_resolves_appcontainer_while_container_fails_closed() {
3840 let session = tempfile::tempdir().unwrap();
3841 let mission = tempfile::tempdir().unwrap();
3842
3843 for enforce in [
3844 crate::types::SandboxEnforce::Fs,
3845 crate::types::SandboxEnforce::FsNet,
3846 ] {
3847 let process = crate::types::SandboxConfig {
3848 enforce,
3849 provider: crate::types::SandboxProvider::Process,
3850 image: None,
3851 extra_write: vec![],
3852 egress: vec![],
3853 };
3854 let (resolved, warning) = resolve_for_session_target(
3855 &process,
3856 session.path(),
3857 mission.path(),
3858 "windows",
3859 false,
3860 Some(crate::sandbox_container::ContainerRuntime::Docker),
3861 None,
3862 );
3863 assert!(warning.is_none(), "{warning:?}");
3864 let resolved = resolved.expect("Windows process enforcement resolves");
3865 assert_eq!(resolved.backend, SandboxBackend::AppContainer);
3866 assert_eq!(resolved.inputs.enforce, enforce);
3867 assert_eq!(resolved.inputs.session_cwd, session.path());
3868 assert_eq!(resolved.inputs.mission_dir, mission.path());
3869
3870 let container = container_cfg(enforce, vec![]);
3871 let (resolved, warning) = resolve_for_session_target(
3872 &container,
3873 session.path(),
3874 mission.path(),
3875 "windows",
3876 false,
3877 Some(crate::sandbox_container::ContainerRuntime::Docker),
3878 None,
3879 );
3880 assert!(resolved.is_none());
3881 let warning = warning.expect("an unproved Windows container must refuse");
3882 assert!(
3886 warning.contains("not supported on target_os=windows"),
3887 "{warning}"
3888 );
3889 assert!(
3890 warning.contains("unverified container mount contract"),
3891 "{warning}"
3892 );
3893 }
3894
3895 let off = container_cfg(crate::types::SandboxEnforce::Off, vec![]);
3896 let (resolved, warning) = resolve_for_session_target(
3897 &off,
3898 session.path(),
3899 mission.path(),
3900 "windows",
3901 false,
3902 Some(crate::sandbox_container::ContainerRuntime::Docker),
3903 None,
3904 );
3905 assert!(resolved.is_none());
3906 assert!(warning.is_none());
3907 }
3908
3909 #[test]
3910 fn container_provider_fs_net_with_egress_list_resolves_for_the_proxy() {
3911 let cfg = container_cfg(
3912 crate::types::SandboxEnforce::FsNet,
3913 vec!["crates.io:443".to_string()],
3914 );
3915 let session = tempfile::tempdir().unwrap();
3916 let mission = tempfile::tempdir().unwrap();
3917
3918 let (resolved, warn) = resolve_for_session_target(
3921 &cfg,
3922 session.path(),
3923 mission.path(),
3924 "linux",
3925 false,
3926 Some(crate::sandbox_container::ContainerRuntime::Docker),
3927 None,
3928 );
3929 assert!(warn.is_none(), "{warn:?}");
3930 let resolved = resolved.expect("container fs+net with egress must resolve");
3931 assert_eq!(resolved.backend, SandboxBackend::Container);
3932 assert_eq!(resolved.inputs.egress, vec!["crates.io:443".to_string()]);
3933 }
3934
3935 #[test]
3936 fn container_provider_fs_net_with_egress_refuses_non_docker_runtime() {
3937 let cfg = container_cfg(
3938 crate::types::SandboxEnforce::FsNet,
3939 vec!["crates.io:443".to_string()],
3940 );
3941 let session = tempfile::tempdir().unwrap();
3942 let mission = tempfile::tempdir().unwrap();
3943 let (resolved, warning) = resolve_for_session_target(
3944 &cfg,
3945 session.path(),
3946 mission.path(),
3947 "linux",
3948 false,
3949 Some(crate::sandbox_container::ContainerRuntime::Podman),
3950 None,
3951 );
3952 assert!(resolved.is_none());
3953 let warning = warning.expect("unproved runtime must fail closed");
3954 assert!(warning.contains("requires Docker"), "{warning}");
3955 assert!(warning.contains("podman"), "{warning}");
3956 }
3957
3958 #[test]
3959 fn container_provider_resolves_runtime_and_image() {
3960 let session = tempfile::tempdir().unwrap();
3961 let mission = tempfile::tempdir().unwrap();
3962
3963 let cfg = container_cfg(crate::types::SandboxEnforce::FsNet, vec![]);
3965 let (resolved, warn) = resolve_for_session_target(
3966 &cfg,
3967 session.path(),
3968 mission.path(),
3969 "linux",
3970 false,
3971 Some(crate::sandbox_container::ContainerRuntime::Podman),
3972 None,
3973 );
3974 assert!(warn.is_none());
3975 let resolved = resolved.expect("runtime present and policy supportable");
3976 assert_eq!(resolved.backend, SandboxBackend::Container);
3977 let container = resolved.container.expect("container spec must be set");
3978 assert_eq!(
3979 container.runtime,
3980 crate::sandbox_container::ContainerRuntime::Podman
3981 );
3982 assert_eq!(container.image, crate::sandbox_container::DEFAULT_IMAGE);
3983
3984 let mut cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3986 cfg.image = Some("ghcr.io/example/kranz-worker:1".to_string());
3987 let (resolved, warn) = resolve_for_session_target(
3988 &cfg,
3989 session.path(),
3990 mission.path(),
3991 "linux",
3992 false,
3993 Some(crate::sandbox_container::ContainerRuntime::Docker),
3994 None,
3995 );
3996 assert!(warn.is_none());
3997 assert_eq!(
3998 resolved
3999 .expect("runtime present")
4000 .container
4001 .expect("container spec")
4002 .image,
4003 "ghcr.io/example/kranz-worker:1"
4004 );
4005 }
4006
4007 #[cfg(target_os = "macos")]
4008 #[test]
4009 fn sandbox_resolve_fs_on_macos_yields_resolved_sandbox() {
4010 let cfg = crate::types::SandboxConfig {
4011 enforce: crate::types::SandboxEnforce::Fs,
4012 provider: crate::types::SandboxProvider::Process,
4013 image: None,
4014 extra_write: vec![],
4015 egress: vec![],
4016 };
4017 let session = tempfile::tempdir().unwrap();
4018 let mission = tempfile::tempdir().unwrap();
4019
4020 let (resolved, warn) = resolve_for_session(&cfg, session.path(), mission.path());
4021 assert!(warn.is_none());
4022 let resolved = resolved.expect("expected an enforced sandbox on macos");
4023 assert_eq!(resolved.backend, SandboxBackend::Seatbelt);
4024 assert_eq!(resolved.inputs.session_cwd, session.path());
4025 assert_eq!(resolved.inputs.mission_dir, mission.path());
4026 assert!(!resolved.inputs.tmpdir.as_os_str().is_empty());
4027 }
4028
4029 #[cfg(target_os = "macos")]
4030 #[test]
4031 fn sandbox_resolve_prewarms_apple_git_cache_before_profile_use() {
4032 use std::process::Command;
4033
4034 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4035 if !sandbox_exec_can_apply() {
4036 return;
4037 }
4038
4039 let repo = tempfile::tempdir().unwrap();
4040 let init = Command::new("/usr/bin/git")
4041 .args(["init", "--quiet"])
4042 .current_dir(repo.path())
4043 .env("GIT_CONFIG_NOSYSTEM", "1")
4044 .env("GIT_CONFIG_GLOBAL", "/dev/null")
4045 .status()
4046 .expect("initialize disposable repository");
4047 assert!(init.success());
4048 let mission = tempfile::tempdir().unwrap();
4049 let cfg = crate::types::SandboxConfig {
4050 enforce: crate::types::SandboxEnforce::Fs,
4051 provider: crate::types::SandboxProvider::Process,
4052 image: None,
4053 extra_write: vec![],
4054 egress: vec![],
4055 };
4056
4057 let (resolved, warn) = resolve_for_session(&cfg, repo.path(), mission.path());
4060 assert!(warn.is_none());
4061 let resolved = resolved.expect("Seatbelt resolves on macOS");
4062 let profile_dir = tempfile::tempdir().unwrap();
4063 let profile_path =
4064 write_profile_file(profile_dir.path(), &generate_profile(&resolved.inputs)).unwrap();
4065 let output = Command::new("sandbox-exec")
4066 .arg("-f")
4067 .arg(profile_path)
4068 .arg("/usr/bin/git")
4069 .args(["status", "--short"])
4070 .current_dir(repo.path())
4071 .env("GIT_CONFIG_NOSYSTEM", "1")
4072 .env("GIT_CONFIG_GLOBAL", "/dev/null")
4073 .output()
4074 .expect("run Apple Git under the resolved profile");
4075 let stderr = String::from_utf8_lossy(&output.stderr);
4076 assert!(output.status.success(), "Apple Git must run: {stderr}");
4077 assert!(
4078 !stderr.contains("xcrun_db"),
4079 "the host-side prewarm must prevent an in-sandbox cache refresh: {stderr}"
4080 );
4081 }
4082
4083 #[cfg(target_os = "macos")]
4084 #[test]
4085 fn sandbox_resolve_expands_tilde_extra_write_via_home() {
4086 let cfg = crate::types::SandboxConfig {
4087 enforce: crate::types::SandboxEnforce::Fs,
4088 provider: crate::types::SandboxProvider::Process,
4089 image: None,
4090 extra_write: vec!["~/.cargo".to_string()],
4091 egress: vec![],
4092 };
4093 let session = tempfile::tempdir().unwrap();
4094 let mission = tempfile::tempdir().unwrap();
4095 let home = std::env::var("HOME").expect("HOME must be set to run this test");
4096
4097 let (resolved, _warn) = resolve_for_session(&cfg, session.path(), mission.path());
4098 let resolved = resolved.expect("expected an enforced sandbox on macos");
4099 assert_eq!(
4100 resolved.inputs.extra_write,
4101 vec![PathBuf::from(home).join(".cargo")]
4102 );
4103 }
4104
4105 #[cfg(target_os = "macos")]
4106 #[test]
4107 fn sandbox_resolve_fs_net_on_macos_yields_seatbelt_with_loopback_profile() {
4108 let cfg = crate::types::SandboxConfig {
4109 enforce: crate::types::SandboxEnforce::FsNet,
4110 provider: crate::types::SandboxProvider::Process,
4111 image: None,
4112 extra_write: vec![],
4113 egress: vec![],
4114 };
4115 let session = tempfile::tempdir().unwrap();
4116 let mission = tempfile::tempdir().unwrap();
4117
4118 let (resolved, warn) = resolve_for_session(&cfg, session.path(), mission.path());
4119
4120 assert!(warn.is_none(), "fs+net on macOS resolves: {warn:?}");
4121 let resolved = resolved.expect("fs+net on macOS resolves to Seatbelt");
4122 assert_eq!(resolved.backend, SandboxBackend::Seatbelt);
4123 let profile = generate_profile(&resolved.inputs);
4124 assert!(
4125 profile.contains("(allow network-outbound (remote tcp \"localhost:*\"))"),
4126 "fs+net profile must restrict egress to loopback so only the egress proxy is reachable:\n{profile}"
4127 );
4128 }
4129
4130 #[cfg(target_os = "macos")]
4131 #[test]
4132 fn sandbox_enforcement_macos_allows_inside_denies_outside() {
4133 use std::process::Command;
4134
4135 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4136
4137 if !sandbox_exec_can_apply() {
4138 return;
4139 }
4140
4141 let session = tempfile::tempdir().unwrap();
4142 let mission = tempfile::tempdir().unwrap();
4143 let tmp = tempfile::tempdir().unwrap();
4144 let outside = tempfile::tempdir().unwrap();
4145
4146 let profile = generate_profile(&inputs(session.path(), mission.path(), tmp.path(), vec![]));
4147 let profile_dir = tempfile::tempdir().unwrap();
4148 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4149
4150 let inside_file = session.path().join("inside.txt");
4151 let inside_status = Command::new("sandbox-exec")
4152 .arg("-f")
4153 .arg(&profile_path)
4154 .arg("/bin/sh")
4155 .arg("-c")
4156 .arg(format!("echo hi > {}", inside_file.display()))
4157 .status()
4158 .expect("failed to run sandbox-exec");
4159 assert!(
4160 inside_status.success(),
4161 "expected write inside session_cwd to succeed"
4162 );
4163 assert!(inside_file.exists(), "expected inside file to be created");
4164
4165 let dev_null_status = Command::new("sandbox-exec")
4166 .arg("-f")
4167 .arg(&profile_path)
4168 .arg("/bin/sh")
4169 .arg("-c")
4170 .arg("echo hi > /dev/null 2>&1")
4171 .status()
4172 .expect("failed to run sandbox-exec");
4173 assert!(
4174 dev_null_status.success(),
4175 "ordinary shell redirects to /dev/null must succeed"
4176 );
4177
4178 let outside_file = outside.path().join(format!(
4179 "kranz_sandbox_should_fail_{}",
4180 uuid::Uuid::new_v4()
4181 ));
4182 let outside_status = Command::new("sandbox-exec")
4183 .arg("-f")
4184 .arg(&profile_path)
4185 .arg("/bin/sh")
4186 .arg("-c")
4187 .arg(format!("echo hi > {}", outside_file.display()))
4188 .status()
4189 .expect("failed to run sandbox-exec");
4190 assert!(
4191 !outside_status.success(),
4192 "expected write outside allowlist to be denied"
4193 );
4194 assert!(
4195 !outside_file.exists(),
4196 "denied write must not have created the file"
4197 );
4198 }
4199
4200 #[cfg(target_os = "macos")]
4201 #[test]
4202 fn sandbox_enforcement_macos_denies_authority_material_reads() {
4203 use std::process::Command;
4204
4205 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4206
4207 if !sandbox_exec_can_apply() {
4208 return;
4209 }
4210
4211 let repo = tempfile::tempdir().unwrap();
4212 let mission = repo.path().join(".kranz").join("missions").join("m-x");
4213 std::fs::create_dir_all(&mission).unwrap();
4214 let tmp = tempfile::tempdir().unwrap();
4215 let kranz_dir = repo.path().join(".kranz");
4216 for name in [
4217 "serve.token",
4218 "serve.read.token",
4219 "config.json",
4220 "domain-terms.local",
4221 ] {
4222 std::fs::write(kranz_dir.join(name), "secret").unwrap();
4223 }
4224 let public = repo.path().join("public.txt");
4225 std::fs::write(&public, "public").unwrap();
4226
4227 let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
4228 let profile_dir = tempfile::tempdir().unwrap();
4229 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4230
4231 for name in [
4232 "serve.token",
4233 "serve.read.token",
4234 "config.json",
4235 "domain-terms.local",
4236 ] {
4237 let status = Command::new("sandbox-exec")
4238 .arg("-f")
4239 .arg(&profile_path)
4240 .arg("/bin/cat")
4241 .arg(kranz_dir.join(name))
4242 .status()
4243 .expect("failed to run sandbox-exec");
4244 assert!(
4245 !status.success(),
4246 "sandboxed read of .kranz/{name} must be denied"
4247 );
4248 }
4249
4250 let output = Command::new("sandbox-exec")
4252 .arg("-f")
4253 .arg(&profile_path)
4254 .arg("/bin/cat")
4255 .arg(&public)
4256 .output()
4257 .expect("failed to run sandbox-exec");
4258 assert!(
4259 output.status.success(),
4260 "ordinary repo reads must keep working: {}",
4261 String::from_utf8_lossy(&output.stderr)
4262 );
4263 assert_eq!(String::from_utf8_lossy(&output.stdout), "public");
4264 }
4265
4266 #[cfg(target_os = "macos")]
4267 #[test]
4268 fn sandbox_enforcement_macos_denies_mission_metadata_writes() {
4269 use std::process::Command;
4270
4271 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4272
4273 if !sandbox_exec_can_apply() {
4274 return;
4275 }
4276
4277 let repo = tempfile::tempdir().unwrap();
4280 let mission = repo.path().join(".kranz").join("missions").join("m-x");
4281 let runs = mission.join("runs");
4282 std::fs::create_dir_all(&runs).unwrap();
4283 let control = mission.join("control");
4284 std::fs::create_dir_all(&control).unwrap();
4285 let contract_home = runs.join("contract-home");
4286 std::fs::create_dir_all(&contract_home).unwrap();
4287 let events = mission.join("events.jsonl");
4288 let state = mission.join("state.json");
4289 let old_transcript = runs.join("run-old.jsonl");
4290 std::fs::write(&events, "{\"seq\":1}\n").unwrap();
4291 std::fs::write(&state, "{}").unwrap();
4292 std::fs::write(&old_transcript, "original\n").unwrap();
4293 let scratch = tempfile::tempdir().unwrap();
4294
4295 let profile = generate_profile(&inputs(repo.path(), &mission, scratch.path(), vec![]));
4296 let profile_dir = tempfile::tempdir().unwrap();
4297 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4298
4299 let denied_writes = [
4302 format!("echo tampered >> {}", events.display()),
4303 format!("echo tampered > {}", state.display()),
4304 format!("echo x > {}", control.join("approve.json").display()),
4305 format!("echo forged >> {}", old_transcript.display()),
4306 format!("echo forged > {}", runs.join("run-new.jsonl").display()),
4307 ];
4308 for write in denied_writes {
4309 let status = Command::new("sandbox-exec")
4310 .arg("-f")
4311 .arg(&profile_path)
4312 .arg("/bin/sh")
4313 .arg("-c")
4314 .arg(&write)
4315 .status()
4316 .expect("failed to run sandbox-exec");
4317 assert!(!status.success(), "write must be denied: {write}");
4318 }
4319 assert_eq!(std::fs::read_to_string(&events).unwrap(), "{\"seq\":1}\n");
4320 assert_eq!(std::fs::read_to_string(&state).unwrap(), "{}");
4321 assert_eq!(
4322 std::fs::read_to_string(&old_transcript).unwrap(),
4323 "original\n"
4324 );
4325 assert!(!runs.join("run-new.jsonl").exists());
4326 assert!(std::fs::read_dir(&control).unwrap().next().is_none());
4327
4328 let allowed_writes = [
4331 repo.path().join("src.txt"),
4332 scratch.path().join("notes.txt"),
4333 contract_home.join("out.txt"),
4334 ];
4335 for target in allowed_writes {
4336 let status = Command::new("sandbox-exec")
4337 .arg("-f")
4338 .arg(&profile_path)
4339 .arg("/bin/sh")
4340 .arg("-c")
4341 .arg(format!("echo ok > {}", target.display()))
4342 .status()
4343 .expect("failed to run sandbox-exec");
4344 assert!(
4345 status.success(),
4346 "write must be allowed: {}",
4347 target.display()
4348 );
4349 assert!(target.exists());
4350 }
4351 }
4352
4353 #[cfg(target_os = "macos")]
4358 #[test]
4359 fn sandbox_enforcement_macos_denies_authority_and_git_metadata_writes() {
4360 use std::process::Command;
4361
4362 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4363
4364 if !sandbox_exec_can_apply() {
4365 return;
4366 }
4367
4368 let (repo, mission) = authority_write_fixture();
4369 let scratch = tempfile::tempdir().unwrap();
4370 let kranz = repo.path().join(".kranz");
4371 let git = repo.path().join(".git");
4372 std::fs::write(git.join("index"), "idx").unwrap();
4373
4374 let profile = generate_profile(&inputs(repo.path(), &mission, scratch.path(), vec![]));
4375 let profile_dir = tempfile::tempdir().unwrap();
4376 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4377
4378 let denied = [
4379 format!("echo '{{}}' > {}", kranz.join("config.json").display()),
4381 format!(
4383 "echo x > {}",
4384 kranz
4385 .join("missions")
4386 .join("m-other")
4387 .join("approve.json")
4388 .display()
4389 ),
4390 format!(
4392 "mkdir {}",
4393 kranz.join("missions").join("m-forged").display()
4394 ),
4395 format!("mkdir {}", kranz.join("newstore").display()),
4396 format!("echo x > {}", kranz.join("queue").join("q.json").display()),
4398 format!("echo x > {}", kranz.join("lessons").join("l.md").display()),
4399 format!(
4402 "echo x > {}",
4403 git.join("hooks").join("pre-commit").display()
4404 ),
4405 format!("echo x > {}", git.join("config").display()),
4406 ];
4407 for command in &denied {
4408 let status = Command::new("sandbox-exec")
4409 .arg("-f")
4410 .arg(&profile_path)
4411 .arg("/bin/sh")
4412 .arg("-c")
4413 .arg(command)
4414 .status()
4415 .expect("failed to run sandbox-exec");
4416 assert!(!status.success(), "write must be denied: {command}");
4417 }
4418 assert_eq!(
4419 std::fs::read_to_string(kranz.join("config.json")).unwrap(),
4420 "secret"
4421 );
4422 assert!(!kranz.join("missions").join("m-forged").exists());
4423 assert!(!kranz.join("newstore").exists());
4424 assert!(!git.join("hooks").join("pre-commit").exists());
4425
4426 let allowed = [
4430 repo.path().join("src.txt"),
4431 scratch.path().join("notes.txt"),
4432 mission.join("runs").join("scratch").join("out.txt"),
4433 git.join("index"),
4434 ];
4435 for target in allowed {
4436 let status = Command::new("sandbox-exec")
4437 .arg("-f")
4438 .arg(&profile_path)
4439 .arg("/bin/sh")
4440 .arg("-c")
4441 .arg(format!("echo ok > {}", target.display()))
4442 .status()
4443 .expect("failed to run sandbox-exec");
4444 assert!(
4445 status.success(),
4446 "write must be allowed: {}",
4447 target.display()
4448 );
4449 }
4450 }
4451
4452 #[cfg(target_os = "macos")]
4453 #[test]
4454 fn sandbox_enforcement_macos_denies_sibling_temp_neighbors() {
4455 use std::process::Command;
4456
4457 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4458
4459 if !sandbox_exec_can_apply() {
4460 return;
4461 }
4462
4463 let root = tempfile::tempdir().unwrap();
4468 let session = root.path().join("kranz-wt-aaa-m1-f-1-1");
4469 let scratch = root.path().join("kranz-worker-home-sess-1");
4470 let scratch_home = scratch.join("home");
4471 let sibling = root.path().join("kranz-wt-bbb-m2-_integration");
4472 let sibling_scratch = root.path().join("kranz-worker-home-sess-2");
4473 for d in [&session, &scratch_home, &sibling, &sibling_scratch] {
4474 std::fs::create_dir_all(d).unwrap();
4475 }
4476 let mission = tempfile::tempdir().unwrap();
4477
4478 let profile = generate_profile(&inputs(&session, mission.path(), &scratch, vec![]));
4479 let profile_dir = tempfile::tempdir().unwrap();
4480 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4481
4482 for allowed in [session.join("code.rs"), scratch_home.join("notes.txt")] {
4483 let status = Command::new("sandbox-exec")
4484 .arg("-f")
4485 .arg(&profile_path)
4486 .arg("/bin/sh")
4487 .arg("-c")
4488 .arg(format!("echo ok > {}", allowed.display()))
4489 .status()
4490 .expect("failed to run sandbox-exec");
4491 assert!(
4492 status.success(),
4493 "write inside the session's own roots must be allowed: {}",
4494 allowed.display()
4495 );
4496 assert!(allowed.exists());
4497 }
4498
4499 for denied in [
4500 sibling.join("evil.txt"),
4501 sibling_scratch.join("evil.txt"),
4502 root.path().join("evil.txt"),
4503 ] {
4504 let status = Command::new("sandbox-exec")
4505 .arg("-f")
4506 .arg(&profile_path)
4507 .arg("/bin/sh")
4508 .arg("-c")
4509 .arg(format!("echo evil > {}", denied.display()))
4510 .status()
4511 .expect("failed to run sandbox-exec");
4512 assert!(
4513 !status.success(),
4514 "write to a temp neighbor must be denied: {}",
4515 denied.display()
4516 );
4517 assert!(!denied.exists());
4518 }
4519 }
4520
4521 #[cfg(target_os = "macos")]
4522 #[test]
4523 fn sandbox_enforcement_macos_fs_net_loopback_profile_applies() {
4524 use std::process::Command;
4525
4526 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4527
4528 if !sandbox_exec_can_apply() {
4529 return;
4530 }
4531
4532 let session = tempfile::tempdir().unwrap();
4533 let mission = tempfile::tempdir().unwrap();
4534 let tmp = tempfile::tempdir().unwrap();
4535 let mut inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
4536 inputs.enforce = crate::types::SandboxEnforce::FsNet;
4537
4538 let profile = generate_profile(&inputs);
4542 let profile_dir = tempfile::tempdir().unwrap();
4543 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4544
4545 let applied = Command::new("sandbox-exec")
4546 .arg("-f")
4547 .arg(&profile_path)
4548 .arg("/usr/bin/true")
4549 .output()
4550 .expect("failed to run sandbox-exec");
4551 assert!(
4552 applied.status.success(),
4553 "loopback-only fs+net profile must apply cleanly on macOS: {}",
4554 String::from_utf8_lossy(&applied.stderr)
4555 );
4556 }
4557
4558 #[cfg(target_os = "linux")]
4559 #[test]
4560 fn sandbox_enforcement_linux_bwrap_allows_inside_denies_outside() {
4561 use std::process::Command;
4562
4563 if !bwrap_can_apply() {
4564 return;
4565 }
4566
4567 let session = tempfile::tempdir().unwrap();
4568 let mission = tempfile::tempdir().unwrap();
4569 let tmp = tempfile::tempdir().unwrap();
4570 let outside = tempfile::tempdir().unwrap();
4571 let inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
4572
4573 let inside_file = session.path().join("inside.txt");
4574 let inside_args = bubblewrap_args(
4575 &inputs,
4576 Path::new("/bin/sh"),
4577 &["-c".into(), format!("echo hi > {}", inside_file.display())],
4578 )
4579 .unwrap();
4580 let inside_status = Command::new("bwrap")
4581 .args(inside_args)
4582 .status()
4583 .expect("failed to run bwrap");
4584 assert!(
4585 inside_status.success(),
4586 "expected write inside session_cwd to succeed"
4587 );
4588 assert!(inside_file.exists(), "expected inside file to be created");
4589
4590 let outside_file = outside
4591 .path()
4592 .join(format!("kranz_bwrap_should_fail_{}", uuid::Uuid::new_v4()));
4593 let outside_args = bubblewrap_args(
4594 &inputs,
4595 Path::new("/bin/sh"),
4596 &["-c".into(), format!("echo hi > {}", outside_file.display())],
4597 )
4598 .unwrap();
4599 let outside_status = Command::new("bwrap")
4600 .args(outside_args)
4601 .status()
4602 .expect("failed to run bwrap");
4603 assert!(
4604 !outside_status.success(),
4605 "expected write outside allowlist to be denied"
4606 );
4607 assert!(
4608 !outside_file.exists(),
4609 "denied write must not have created the file"
4610 );
4611 }
4612
4613 #[cfg(target_os = "linux")]
4614 #[test]
4615 fn sandbox_enforcement_linux_bwrap_tolerates_disappearing_visible_entries() {
4616 if crate::agent_env::isolated_global_home_test(
4617 "sandbox::tests::sandbox_enforcement_linux_bwrap_tolerates_disappearing_visible_entries",
4618 ) {
4619 return;
4620 }
4621 if !bwrap_can_apply() {
4622 return;
4623 }
4624 let repo = tempfile::tempdir().unwrap();
4625 let scratch = tempfile::tempdir().unwrap();
4626 let home = tempfile::tempdir().unwrap();
4627 let _env =
4628 crate::agent_env::EnvTestGuard::engage(&[("HOME", home.path().to_str().unwrap())]);
4629 let kranz = home.path().join(".kranz");
4630 let mission = repo.path().join(".kranz/missions/m-mask-race");
4631 std::fs::create_dir_all(&mission).unwrap();
4632 let transient_dir = home.path().join("temporary-cache");
4633 let transient_file = home.path().join("temporary-note");
4634 let public = home.path().join("public.txt");
4635 let authority_path = repo.path().join(".kranz/serve.token");
4636 let late_authority_path = kranz.join("serve.read.token");
4637 let writable = repo.path().join("result.txt");
4638 std::fs::create_dir(&transient_dir).unwrap();
4639 std::fs::write(&transient_file, "temporary").unwrap();
4640 std::fs::write(&public, "public").unwrap();
4641 std::fs::write(&authority_path, "secret").unwrap();
4642 let args = bubblewrap_args(
4643 &inputs(repo.path(), &mission, scratch.path(), vec![]),
4644 Path::new("/bin/sh"),
4645 &[
4646 "-c".into(),
4647 "test ! -e \"$1\" && test ! -e \"$2\" \
4648 && test \"$(cat \"$3\")\" = public && ! touch \"$3\" \
4649 && test ! -e \"$4\" && test ! -e \"$5\" \
4650 && printf ok > \"$6\""
4651 .into(),
4652 "mask-race".into(),
4653 transient_dir.display().to_string(),
4654 transient_file.display().to_string(),
4655 public.display().to_string(),
4656 authority_path.display().to_string(),
4657 late_authority_path.display().to_string(),
4658 writable.display().to_string(),
4659 ],
4660 )
4661 .unwrap();
4662 for path in [&transient_dir, &transient_file] {
4663 let path = absolutize(path).display().to_string();
4664 assert!(args.windows(3).any(|part| {
4665 matches!(part[0].as_str(), "--ro-bind" | "--ro-bind-try")
4666 && part[1] == path
4667 && part[2] == path
4668 }));
4669 }
4670 std::fs::remove_dir(&transient_dir).unwrap();
4673 std::fs::remove_file(&transient_file).unwrap();
4674 std::fs::create_dir(&kranz).unwrap();
4675 std::fs::write(&late_authority_path, "late-secret").unwrap();
4676 let output = std::process::Command::new("bwrap")
4677 .args(args)
4678 .env_clear()
4679 .env("PATH", "/usr/bin:/bin")
4680 .output()
4681 .unwrap();
4682 assert!(
4683 output.status.success(),
4684 "missing ordinary entries must stay hidden without breaking the sandbox: {output:?}"
4685 );
4686 assert_eq!(std::fs::read_to_string(&writable).unwrap(), "ok");
4687 assert_eq!(std::fs::read_to_string(&public).unwrap(), "public");
4688 assert_eq!(std::fs::read_to_string(&authority_path).unwrap(), "secret");
4689 assert_eq!(
4690 std::fs::read_to_string(&late_authority_path).unwrap(),
4691 "late-secret"
4692 );
4693 }
4694
4695 #[cfg(unix)]
4696 #[test]
4697 fn sandbox_bwrap_rebinding_keeps_git_and_authority_protected() {
4698 if crate::agent_env::isolated_global_home_test(
4699 "sandbox::tests::sandbox_bwrap_rebinding_keeps_git_and_authority_protected",
4700 ) {
4701 return;
4702 }
4703 let dir = tempfile::tempdir().unwrap();
4704 let home = dir.path().canonicalize().unwrap();
4705 let cargo = home.join(".cargo");
4706 std::fs::create_dir(home.join(".kranz")).unwrap();
4707 let _env = crate::agent_env::EnvTestGuard::engage(&[
4708 ("HOME", home.to_str().unwrap()),
4709 ("CARGO_HOME", cargo.to_str().unwrap()),
4710 ]);
4711 assert!(!cargo.exists());
4714 for snapshot in [false, true] {
4715 let repo = home.join(if snapshot {
4716 "validator-repo"
4717 } else {
4718 "checkout"
4719 });
4720 let mission = repo.join(".kranz/missions/m-rebind");
4721 let scratch = mission.join("runs/scratch");
4722 let cwd = if snapshot {
4723 mission.join("runs/snapshot")
4724 } else {
4725 repo.clone()
4726 };
4727 for path in [&cwd, &scratch] {
4728 std::fs::create_dir_all(path).unwrap();
4729 }
4730 let protected = if snapshot {
4731 vec![cwd.join(".git")]
4732 } else {
4733 std::fs::create_dir_all(cwd.join(".git/hooks")).unwrap();
4734 vec![cwd.join(".git/config"), cwd.join(".git/hooks/probe")]
4735 };
4736 for path in &protected {
4737 std::fs::write(path, "protected").unwrap();
4738 }
4739 let authority_path = repo.join(".kranz/serve.token");
4740 std::fs::write(&authority_path, "secret").unwrap();
4741 let ordinary = if snapshot {
4742 cwd.join("witness")
4743 } else {
4744 cwd.join(".git/index")
4745 };
4746 let mut command = vec![
4747 "-c".into(),
4748 "printf work > \"$1\" || exit 1; printf work > \"$2\" || exit 2; \
4749 if cat \"$3\"; then exit 3; fi; shift 3; \
4750 for path in \"$@\"; do \
4751 test \"$(cat \"$path\")\" = protected || exit 4; \
4752 if printf forged > \"$path\"; then exit 5; fi; done"
4753 .into(),
4754 "rebind-test".into(),
4755 ordinary.display().to_string(),
4756 scratch.join("witness").display().to_string(),
4757 authority_path.display().to_string(),
4758 ];
4759 command.extend(protected.iter().map(|path| path.display().to_string()));
4760 let args = bubblewrap_args(
4761 &inputs(&cwd, &mission, &scratch, vec![]),
4762 Path::new("/bin/sh"),
4763 &command,
4764 )
4765 .unwrap();
4766 for path in &protected {
4767 let last_bind = args.windows(3).rev().find(|part| {
4768 matches!(part[0].as_str(), "--bind" | "--ro-bind" | "--ro-bind-try")
4769 && path.starts_with(&part[2])
4770 });
4771 assert_eq!(
4772 last_bind.map(|part| part[0].as_str()),
4773 Some("--ro-bind"),
4774 "a later writable ancestor reopened {}: {args:?}",
4775 path.display()
4776 );
4777 }
4778 #[cfg(target_os = "linux")]
4779 if bwrap_can_apply() {
4780 let output = std::process::Command::new("bwrap")
4781 .args(&args)
4782 .env_clear()
4783 .env("PATH", "/usr/bin:/bin")
4784 .output()
4785 .unwrap();
4786 assert!(output.status.success(), "snapshot={snapshot}: {output:?}");
4787 assert_eq!(std::fs::read_to_string(&ordinary).unwrap(), "work");
4788 assert_eq!(std::fs::read_to_string(&authority_path).unwrap(), "secret");
4789 for path in protected {
4790 assert_eq!(std::fs::read_to_string(path).unwrap(), "protected");
4791 }
4792 }
4793 }
4794 }
4795
4796 #[cfg(target_os = "linux")]
4797 #[test]
4798 fn sandbox_enforcement_linux_bwrap_masks_authority_material() {
4799 if crate::agent_env::isolated_global_home_test(
4800 "sandbox::tests::sandbox_enforcement_linux_bwrap_masks_authority_material",
4801 ) {
4802 return;
4803 }
4804 use std::process::Command;
4805
4806 if !bwrap_can_apply() {
4807 return;
4808 }
4809
4810 let repo = tempfile::tempdir().unwrap();
4811 let mission = repo.path().join(".kranz").join("missions").join("m-x");
4812 std::fs::create_dir_all(&mission).unwrap();
4813 let tmp = tempfile::tempdir().unwrap();
4814 let serve_token = repo.path().join(".kranz").join("serve.token");
4815 std::fs::write(&serve_token, "secret").unwrap();
4816 let public = repo.path().join("public.txt");
4817 std::fs::write(&public, "public").unwrap();
4818 let home = tempfile::tempdir().unwrap();
4819 let authority_target = tempfile::tempdir().unwrap();
4820 let alias = home.path().join(".kranz");
4821 std::os::unix::fs::symlink(authority_target.path(), &alias).unwrap();
4822 let config_target = authority_target.path().join("settings.json");
4823 std::fs::write(&config_target, "config-secret").unwrap();
4824 std::os::unix::fs::symlink(&config_target, repo.path().join(".kranz/config.json")).unwrap();
4825 let _env =
4826 crate::agent_env::EnvTestGuard::engage(&[("HOME", home.path().to_str().unwrap())]);
4827 let inputs = inputs(repo.path(), &mission, tmp.path(), vec![home.path().into()]);
4828
4829 let masked = Command::new("bwrap")
4831 .args(
4832 bubblewrap_args(
4833 &inputs,
4834 Path::new("/bin/cat"),
4835 &[serve_token.display().to_string()],
4836 )
4837 .unwrap(),
4838 )
4839 .output()
4840 .expect("failed to run bwrap");
4841 assert!(
4842 !masked.status.success(),
4843 "reading the hidden authority path must fail: {}",
4844 String::from_utf8_lossy(&masked.stderr)
4845 );
4846 assert!(
4847 !String::from_utf8_lossy(&masked.stdout).contains("secret"),
4848 "serve.token content must be masked inside the sandbox"
4849 );
4850
4851 let late = repo.path().join(".kranz/serve.read.token");
4852 let args = bubblewrap_args(
4853 &inputs,
4854 Path::new("/bin/cat"),
4855 &[late.display().to_string()],
4856 )
4857 .unwrap();
4858 std::fs::write(&late, "late-secret").unwrap();
4861 let output = Command::new("bwrap").args(args).output().unwrap();
4862 assert!(!output.status.success());
4863 assert!(!String::from_utf8_lossy(&output.stdout).contains("late-secret"));
4864
4865 let host_view = format!("/proc/{}/root{}", std::process::id(), serve_token.display());
4866 let output = Command::new("bwrap")
4867 .args(bubblewrap_args(&inputs, Path::new("/bin/cat"), &[host_view]).unwrap())
4868 .output()
4869 .unwrap();
4870 assert!(
4871 !output.status.success(),
4872 "host /proc roots must not bypass the namespace"
4873 );
4874 assert!(!String::from_utf8_lossy(&output.stdout).contains("secret"));
4875
4876 for (binary, path) in [("/bin/cat", &config_target), ("/bin/rm", &alias)] {
4877 let output = Command::new("bwrap")
4878 .args(
4879 bubblewrap_args(&inputs, Path::new(binary), &[path.display().to_string()])
4880 .unwrap(),
4881 )
4882 .output()
4883 .unwrap();
4884 assert!(
4885 !output.status.success(),
4886 "authority alias/target was exposed: {output:?}"
4887 );
4888 }
4889 assert!(
4890 alias.is_symlink(),
4891 "the operator's authority alias was replaced"
4892 );
4893
4894 let control = Command::new("bwrap")
4895 .args(
4896 bubblewrap_args(
4897 &inputs,
4898 Path::new("/bin/cat"),
4899 &[public.display().to_string()],
4900 )
4901 .unwrap(),
4902 )
4903 .output()
4904 .expect("failed to run bwrap");
4905 assert_eq!(String::from_utf8_lossy(&control.stdout), "public");
4906 }
4907
4908 fn validator_containment_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
4917 let dir = tempfile::tempdir().unwrap();
4918 let root = dir.path().join("repo");
4919 std::fs::create_dir_all(root.join("src")).unwrap();
4920 std::fs::write(root.join("src").join("secret.rs"), "fn secret() {}\n").unwrap();
4921 std::fs::write(root.join("Cargo.toml"), "[package]\n").unwrap();
4922 let dotenv_path = root.join(".env");
4927 std::fs::write(&dotenv_path, "placeholder-content\n").unwrap();
4928 std::fs::create_dir_all(root.join(".git")).unwrap();
4929 std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
4930 let mission = root.join(".kranz").join("missions").join("m-x");
4931 let snapshot = mission.join("runs").join("validator-snapshot-scrutiny");
4932 std::fs::create_dir_all(&snapshot).unwrap();
4933 std::fs::write(snapshot.join("README.md"), "snapshot copy\n").unwrap();
4934 std::fs::write(root.join(".kranz").join("serve.token"), "secret-token").unwrap();
4935 std::fs::write(
4941 root.join(".kranz").join("domain-terms.local"),
4942 "acme widget\n",
4943 )
4944 .unwrap();
4945 let hook_status = root.join(".kranz").join("hook-status").join("m-x");
4946 std::fs::create_dir_all(&hook_status).unwrap();
4947 std::fs::write(hook_status.join("run-1.json"), "{\"tokenHash\":\"abc\"}\n").unwrap();
4948 let control = mission.join("control");
4949 std::fs::create_dir_all(&control).unwrap();
4950 std::fs::write(control.join("approve.json"), "{}\n").unwrap();
4951 (dir, root, snapshot, mission)
4952 }
4953
4954 #[cfg(any(target_os = "macos", target_os = "linux"))]
4960 fn validator_containment_git_fixture() -> Option<(tempfile::TempDir, PathBuf, PathBuf, PathBuf)>
4961 {
4962 let git_ok = std::process::Command::new("git")
4963 .arg("--version")
4964 .output()
4965 .map(|o| o.status.success())
4966 .unwrap_or(false);
4967 if !git_ok {
4968 crate::test_capability::skip(
4969 crate::test_capability::capability::GIT,
4970 "git is not on PATH",
4971 );
4972 return None;
4973 }
4974 let dir = tempfile::tempdir().unwrap();
4975 let root = dir.path().join("repo");
4976 std::fs::create_dir_all(&root).unwrap();
4977 let run = |args: &[&str]| {
4978 let out = std::process::Command::new("git")
4979 .args(args)
4980 .current_dir(&root)
4981 .output()
4982 .expect("spawn git");
4983 assert!(out.status.success(), "git {args:?} failed: {out:?}");
4984 };
4985 if !std::process::Command::new("git")
4986 .args(["init", "-b", "main"])
4987 .current_dir(&root)
4988 .output()
4989 .map(|o| o.status.success())
4990 .unwrap_or(false)
4991 {
4992 run(&["init"]);
4993 run(&["symbolic-ref", "HEAD", "refs/heads/main"]);
4994 }
4995 run(&["config", "user.name", "test"]);
4996 run(&["config", "user.email", "test@example.com"]);
4997 std::fs::create_dir_all(root.join("src")).unwrap();
4998 std::fs::write(root.join("src").join("secret.rs"), "fn secret() {}\n").unwrap();
4999 std::fs::write(root.join("tracked.rs"), "fn tracked() {}\n").unwrap();
5000 std::fs::write(root.join(".gitignore"), ".kranz/\n").unwrap();
5001 run(&["add", "-A"]);
5002 run(&["commit", "-m", "init"]);
5003 let mission = root.join(".kranz").join("missions").join("m-x");
5004 let snapshot = mission.join("runs").join("validator-snapshot-scrutiny");
5005 std::fs::create_dir_all(snapshot.parent().unwrap()).unwrap();
5006 run(&[
5007 "worktree",
5008 "add",
5009 "--detach",
5010 snapshot.to_str().expect("utf-8 temp path"),
5011 ]);
5012 std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
5014 std::fs::write(root.join(".kranz").join("serve.token"), "secret-token").unwrap();
5015 Some((dir, root, snapshot, mission))
5016 }
5017
5018 fn validator_containment_inputs(
5021 root: &Path,
5022 snapshot: &Path,
5023 mission: &Path,
5024 tmpdir: &Path,
5025 ) -> SandboxInputs {
5026 SandboxInputs {
5027 enforce: crate::types::SandboxEnforce::Fs,
5028 session_cwd: snapshot.to_path_buf(),
5029 mission_dir: mission.to_path_buf(),
5030 tmpdir: tmpdir.to_path_buf(),
5031 extra_write: Vec::new(),
5032 egress: Vec::new(),
5033 validator_read_deny_roots: vec![root.to_path_buf()],
5034 }
5035 }
5036
5037 #[test]
5041 fn validator_containment_entries_cover_source_tree_and_carve_out_git_and_kranz() {
5042 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5043 let scratch = tempfile::tempdir().unwrap();
5044 let inputs = validator_containment_inputs(&root, &snapshot, &mission, scratch.path());
5045 let entries = validator_read_deny_entries(&inputs);
5046
5047 for base in [root.clone(), absolutize(&root)] {
5048 let src = base.join("src");
5049 assert!(
5050 entries.contains(&ValidatorReadDenyEntry {
5051 path: src.clone(),
5052 is_dir: true
5053 }),
5054 "src/ must be a denied dir: {entries:?}"
5055 );
5056 for file in ["Cargo.toml", ".env"] {
5057 assert!(
5058 entries.contains(&ValidatorReadDenyEntry {
5059 path: base.join(file),
5060 is_dir: false
5061 }),
5062 "{file} must be a denied file: {entries:?}"
5063 );
5064 }
5065 }
5066 assert!(
5067 entries.iter().all(|e| e
5068 .path
5069 .file_name()
5070 .is_some_and(|n| n != ".git" && n != ".kranz")),
5071 "the carve-outs must never be denied: {entries:?}"
5072 );
5073 }
5074
5075 #[test]
5080 fn validator_containment_profile_read_denies_source_tree_and_keeps_carveouts() {
5081 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5082 let scratch = tempfile::tempdir().unwrap();
5083 let profile = generate_profile(&validator_containment_inputs(
5084 &root,
5085 &snapshot,
5086 &mission,
5087 scratch.path(),
5088 ));
5089 let read_rules: String = profile
5090 .split("(deny file-read*")
5091 .skip(1)
5092 .map(|block| block.split("\n)\n").next().unwrap_or_default())
5093 .collect();
5094
5095 for base in [root.clone(), absolutize(&root)] {
5096 let src = format!("(subpath \"{}\")", escape_sbpl_literal(&base.join("src")));
5097 assert!(
5098 profile.contains(&src),
5099 "profile missing read deny for src/:\n{profile}"
5100 );
5101 for file in ["Cargo.toml", ".env"] {
5102 let lit = format!("(literal \"{}\")", escape_sbpl_literal(&base.join(file)));
5103 assert!(
5104 profile.contains(&lit),
5105 "profile missing read deny for {file}:\n{profile}"
5106 );
5107 }
5108 let root_lit = format!("(literal \"{}\")", escape_sbpl_literal(&base));
5109 assert!(
5110 !read_rules.contains(&root_lit),
5111 "the root itself is deliberately NOT denied (a literal deny breaks \
5112 coreutils `mkdir -p`, which stats every ancestor):\n{profile}"
5113 );
5114 let git_rule = format!("\"{}\"", escape_sbpl_literal(&base.join(".git")));
5117 assert!(
5118 !read_rules.contains(&git_rule),
5119 ".git must stay readable (the inspection's git surface):\n{profile}"
5120 );
5121 let kranz_rule = format!("\"{}\"", escape_sbpl_literal(&base.join(".kranz")));
5122 assert!(
5123 !read_rules.contains(&kranz_rule),
5124 ".kranz must stay reachable (the snapshot lives under it):\n{profile}"
5125 );
5126 }
5127 for base in [root.join(".kranz"), absolutize(&root.join(".kranz"))] {
5129 let token = format!(
5130 "(literal \"{}\")",
5131 escape_sbpl_literal(&base.join("serve.token"))
5132 );
5133 assert!(
5134 profile.contains(&token),
5135 "the authority read deny must survive the carve-out:\n{profile}"
5136 );
5137 }
5138 let snap_rule = format!(
5140 "(subpath \"{}\")",
5141 escape_sbpl_literal(&absolutize(&snapshot))
5142 );
5143 assert!(
5144 profile.contains(&snap_rule),
5145 "the snapshot must stay writable:\n{profile}"
5146 );
5147 assert!(
5150 profile.contains("(allow file-write* (literal \"/dev/null\"))"),
5151 "validator profiles must keep /dev/null writable:\n{profile}"
5152 );
5153 }
5154
5155 #[test]
5163 fn validator_containment_profile_denies_sensitive_kranz_runtime_reads() {
5164 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5165 let scratch = tempfile::tempdir().unwrap();
5166 let profile = generate_profile(&validator_containment_inputs(
5167 &root,
5168 &snapshot,
5169 &mission,
5170 scratch.path(),
5171 ));
5172 let read_rules: String = profile
5173 .split("(deny file-read*")
5174 .skip(1)
5175 .map(|block| block.split("\n)\n").next().unwrap_or_default())
5176 .collect();
5177
5178 let kranz = root.join(".kranz");
5179 for base in [kranz.clone(), absolutize(&kranz)] {
5180 let terms = format!(
5181 "(literal \"{}\")",
5182 escape_sbpl_literal(&base.join("domain-terms.local"))
5183 );
5184 assert!(
5185 profile.contains(&terms),
5186 "profile missing read deny for domain-terms.local:\n{profile}"
5187 );
5188 let hook = format!(
5189 "(subpath \"{}\")",
5190 escape_sbpl_literal(&base.join("hook-status"))
5191 );
5192 assert!(
5193 profile.contains(&hook),
5194 "profile missing read deny for hook-status/:\n{profile}"
5195 );
5196 }
5197 for base in [mission.clone(), absolutize(&mission)] {
5198 let control = format!(
5199 "(subpath \"{}\")",
5200 escape_sbpl_literal(&base.join("control"))
5201 );
5202 assert!(
5203 profile.contains(&control),
5204 "profile missing read deny for the control inbox:\n{profile}"
5205 );
5206 }
5207 for base in [kranz.clone(), absolutize(&kranz)] {
5210 let kranz_rule = format!("\"{}\"", escape_sbpl_literal(&base));
5211 assert!(
5212 !read_rules.contains(&kranz_rule),
5213 ".kranz must stay reachable (the snapshot lives under it):\n{profile}"
5214 );
5215 }
5216 }
5217
5218 #[test]
5221 fn validator_containment_empty_roots_emit_no_deny_block() {
5222 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5223 let scratch = tempfile::tempdir().unwrap();
5224 let mut inputs = validator_containment_inputs(&root, &snapshot, &mission, scratch.path());
5225 inputs.validator_read_deny_roots = Vec::new();
5226 let profile = generate_profile(&inputs);
5227 assert_eq!(
5228 profile.matches("(deny file-read*").count(),
5229 1,
5230 "empty roots must leave the pre-containment profile shape alone:\n{profile}"
5231 );
5232
5233 let profile = generate_profile(&validator_containment_inputs(
5234 &root,
5235 &snapshot,
5236 &mission,
5237 scratch.path(),
5238 ));
5239 assert_eq!(
5240 profile.matches("(deny file-read*").count(),
5241 2,
5242 "the validator read-deny block must land when roots are set:\n{profile}"
5243 );
5244 }
5245
5246 #[test]
5249 fn validator_containment_bwrap_masks_source_tree_and_keeps_carveouts() {
5250 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5251 let scratch = tempfile::tempdir().unwrap();
5252 let args = bubblewrap_args(
5253 &validator_containment_inputs(&root, &snapshot, &mission, scratch.path()),
5254 Path::new("/usr/bin/claude"),
5255 &[],
5256 )
5257 .unwrap();
5258 let joined = args.join(" ");
5259
5260 let src = absolutize(&root.join("src")).display().to_string();
5261 assert!(
5262 args.windows(2).any(|w| w[0] == "--tmpfs" && w[1] == src),
5263 "missing tmpfs shadow for src/: {args:?}"
5264 );
5265 let env_file = absolutize(&root.join(".env")).display().to_string();
5266 assert!(
5267 joined.contains(&format!("--ro-bind /dev/null {env_file}")),
5268 "missing /dev/null mask for .env: {args:?}"
5269 );
5270 let git = absolutize(&root.join(".git")).display().to_string();
5273 assert!(
5274 !joined.contains(&git),
5275 ".git must not be masked (the inspection's git surface): {args:?}"
5276 );
5277 assert!(
5278 !args
5279 .windows(2)
5280 .any(|w| w[0] == "--tmpfs" && w[1] == root.display().to_string()),
5281 "the root itself must not be shadowed: {args:?}"
5282 );
5283 let snap = absolutize(&snapshot).display().to_string();
5285 assert!(
5286 joined.contains(&format!("--bind {snap} {snap}")),
5287 "the snapshot must stay rw-bound: {args:?}"
5288 );
5289 }
5290
5291 #[test]
5297 fn validator_containment_bwrap_masks_sensitive_kranz_runtime() {
5298 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5299 let scratch = tempfile::tempdir().unwrap();
5300 let args = bubblewrap_args(
5301 &validator_containment_inputs(&root, &snapshot, &mission, scratch.path()),
5302 Path::new("/usr/bin/claude"),
5303 &[],
5304 )
5305 .unwrap();
5306 let joined = args.join(" ");
5307
5308 let kranz = absolutize(&root.join(".kranz")).display().to_string();
5309 assert!(
5310 joined.contains(&format!("--tmpfs {kranz}")) && !joined.contains("domain-terms.local"),
5311 "the private authority directory must exclude domain-terms.local: {args:?}"
5312 );
5313 for dir in [
5314 root.join(".kranz").join("hook-status"),
5315 mission.join("control"),
5316 ] {
5317 let shadow = absolutize(dir.parent().unwrap()).display().to_string();
5318 assert!(args.windows(2).any(|w| w[0] == "--tmpfs" && w[1] == shadow));
5319 let denied = absolutize(&dir).display().to_string();
5320 assert!(
5321 !args.windows(3).any(|part| {
5322 matches!(part[0].as_str(), "--ro-bind" | "--ro-bind-try")
5323 && part[1] == denied
5324 && part[2] == denied
5325 }),
5326 "denied directory must not be rebound: {args:?}"
5327 );
5328 }
5329 }
5330
5331 fn off_cfg() -> crate::types::SandboxConfig {
5334 crate::types::SandboxConfig::default()
5335 }
5336
5337 fn fs_cfg() -> crate::types::SandboxConfig {
5338 crate::types::SandboxConfig {
5339 enforce: crate::types::SandboxEnforce::Fs,
5340 ..crate::types::SandboxConfig::default()
5341 }
5342 }
5343
5344 #[test]
5348 fn validator_containment_off_macos_wraps_mandatory_seatbelt() {
5349 let mut cfg = off_cfg();
5350 cfg.extra_write = vec!["~/elsewhere".to_string()];
5351 let roots = vec![PathBuf::from("/repo")];
5352 let containment = resolve_validator_containment_target(
5353 &cfg,
5354 crate::types::BackendKind::Claude,
5355 Path::new("/repo/.kranz/missions/m-x/runs/snap"),
5356 Path::new("/repo/.kranz/missions/m-x"),
5357 &roots,
5358 false,
5359 "macos",
5360 false,
5361 None,
5362 None,
5363 )
5364 .expect("off+macos resolves the mandatory wrap");
5365 assert!(containment.note.is_none(), "{:?}", containment.note);
5366 let sandbox = containment.sandbox.expect("a wrap applies");
5367 assert_eq!(sandbox.backend, SandboxBackend::Seatbelt);
5368 assert_eq!(
5369 sandbox.inputs.enforce,
5370 crate::types::SandboxEnforce::Fs,
5371 "the mandatory wrap is the fs tier (egress stays open for the API)"
5372 );
5373 assert_eq!(sandbox.inputs.validator_read_deny_roots, roots);
5374 assert!(
5375 sandbox.inputs.extra_write.is_empty(),
5376 "no operator extraWrite widening under the mandatory wrap"
5377 );
5378 assert_eq!(
5379 sandbox.inputs.session_cwd,
5380 PathBuf::from("/repo/.kranz/missions/m-x/runs/snap"),
5381 "the snapshot is the writable root"
5382 );
5383 }
5384
5385 #[test]
5391 fn validator_containment_off_linux_without_bwrap_fails_closed_unless_opted_in() {
5392 let roots = vec![PathBuf::from("/repo")];
5393 let err = resolve_validator_containment_target(
5394 &off_cfg(),
5395 crate::types::BackendKind::Claude,
5396 Path::new("/snap"),
5397 Path::new("/mission"),
5398 &roots,
5399 false,
5400 "linux",
5401 false,
5402 None,
5403 None,
5404 )
5405 .expect_err("no bwrap and no opt-in: fail closed");
5406 let err = err.to_string();
5407 assert!(err.contains("bwrap"), "{err}");
5408 assert!(err.contains("validatorAllowUncontainedDegrade"), "{err}");
5409 assert!(
5410 err.contains("refusing to run an uncontained validator"),
5411 "{err}"
5412 );
5413
5414 let containment = resolve_validator_containment_target(
5415 &off_cfg(),
5416 crate::types::BackendKind::Claude,
5417 Path::new("/snap"),
5418 Path::new("/mission"),
5419 &roots,
5420 true,
5421 "linux",
5422 false,
5423 None,
5424 None,
5425 )
5426 .expect("the opt-in restores the loud degrade");
5427 assert!(containment.sandbox.is_none());
5428 let note = containment.note.expect("the loud note");
5429 assert!(note.contains("bwrap"), "{note}");
5430 assert!(note.contains("validator-mandatory-containment"), "{note}");
5431
5432 let containment = resolve_validator_containment_target(
5433 &off_cfg(),
5434 crate::types::BackendKind::Claude,
5435 Path::new("/snap"),
5436 Path::new("/mission"),
5437 &roots,
5438 false,
5439 "linux",
5440 true,
5441 None,
5442 None,
5443 )
5444 .expect("off+linux+bwrap resolves");
5445 assert!(containment.note.is_none(), "{:?}", containment.note);
5446 assert_eq!(
5447 containment.sandbox.expect("a wrap applies").backend,
5448 SandboxBackend::Bubblewrap
5449 );
5450 }
5451
5452 #[test]
5456 fn validator_containment_off_windows_resolves_appcontainer() {
5457 let roots = vec![PathBuf::from("C:\\repo")];
5458 for allow_uncontained_degrade in [false, true] {
5459 let containment = resolve_validator_containment_target(
5460 &off_cfg(),
5461 crate::types::BackendKind::Claude,
5462 Path::new("C:\\snap"),
5463 Path::new("C:\\mission"),
5464 &roots,
5465 allow_uncontained_degrade,
5466 "windows",
5467 false,
5468 None,
5469 None,
5470 )
5471 .expect("Windows resolves the mandatory AppContainer wrap");
5472 assert!(containment.note.is_none(), "{:?}", containment.note);
5473 let sandbox = containment.sandbox.expect("a wrap applies");
5474 assert_eq!(sandbox.backend, SandboxBackend::AppContainer);
5475 assert_eq!(sandbox.inputs.enforce, crate::types::SandboxEnforce::Fs);
5476 assert_eq!(sandbox.inputs.session_cwd, PathBuf::from("C:\\snap"));
5477 assert_eq!(sandbox.inputs.validator_read_deny_roots, roots);
5478 assert!(sandbox.inputs.extra_write.is_empty());
5479 }
5480 }
5481
5482 #[test]
5487 fn validator_containment_off_non_claude_backend_fails_closed_unless_opted_in() {
5488 for backend in [
5489 crate::types::BackendKind::Codex,
5490 crate::types::BackendKind::Droid,
5491 crate::types::BackendKind::Kimi,
5492 crate::types::BackendKind::Local,
5493 crate::types::BackendKind::Acp,
5494 crate::types::BackendKind::Cursor,
5495 ] {
5496 let err = resolve_validator_containment_target(
5497 &off_cfg(),
5498 backend,
5499 Path::new("/snap"),
5500 Path::new("/mission"),
5501 &[PathBuf::from("/repo")],
5502 false,
5503 "macos",
5504 false,
5505 None,
5506 None,
5507 )
5508 .expect_err("an uncontainable backend fails closed by default");
5509 let err = err.to_string();
5510 assert!(err.contains(backend.as_str()), "{err}");
5511 assert!(err.contains("validatorAllowUncontainedDegrade"), "{err}");
5512
5513 let containment = resolve_validator_containment_target(
5514 &off_cfg(),
5515 backend,
5516 Path::new("/snap"),
5517 Path::new("/mission"),
5518 &[PathBuf::from("/repo")],
5519 true,
5520 "macos",
5521 false,
5522 None,
5523 None,
5524 )
5525 .expect("the opt-in restores the loud degrade");
5526 assert!(
5527 containment.sandbox.is_none(),
5528 "{backend:?} must not get a wrap it cannot honor"
5529 );
5530 let note = containment.note.expect("the loud note");
5531 assert!(note.contains(backend.as_str()), "{note}");
5532 assert!(note.contains("validator-mandatory-containment"), "{note}");
5533 }
5534 }
5535
5536 #[test]
5540 fn validator_containment_enforced_role_resolves_and_attaches_roots() {
5541 let mut cfg = fs_cfg();
5542 cfg.extra_write = vec!["~/keep".to_string()];
5543 let roots = vec![PathBuf::from("/repo")];
5544 let containment = resolve_validator_containment_target(
5545 &cfg,
5546 crate::types::BackendKind::Claude,
5547 Path::new("/repo/.kranz/missions/m-x/runs/snap"),
5548 Path::new("/repo/.kranz/missions/m-x"),
5549 &roots,
5550 false,
5551 "macos",
5552 false,
5553 None,
5554 None,
5555 )
5556 .expect("fs on macos resolves");
5557 assert!(containment.note.is_none(), "{:?}", containment.note);
5558 let sandbox = containment.sandbox.expect("the role's wrap");
5559 assert_eq!(sandbox.backend, SandboxBackend::Seatbelt);
5560 assert_eq!(sandbox.inputs.validator_read_deny_roots, roots);
5561 assert!(
5562 !sandbox.inputs.extra_write.is_empty(),
5563 "an enforced role keeps its declared extraWrite"
5564 );
5565 }
5566
5567 #[test]
5570 fn validator_containment_enforced_role_still_fails_closed_where_unsupported() {
5571 let err = resolve_validator_containment_target(
5572 &fs_cfg(),
5573 crate::types::BackendKind::Claude,
5574 Path::new("/snap"),
5575 Path::new("/mission"),
5576 &[PathBuf::from("/repo")],
5577 false,
5578 "solaris",
5579 false,
5580 None,
5581 None,
5582 )
5583 .expect_err("enforcement requested but unhonorable must fail closed");
5584 assert!(err.to_string().contains("unsupported"), "{err}");
5585 }
5586
5587 #[test]
5592 fn validator_containment_container_provider_posture() {
5593 let cfg = crate::types::SandboxConfig {
5594 enforce: crate::types::SandboxEnforce::Fs,
5595 provider: crate::types::SandboxProvider::Container,
5596 ..crate::types::SandboxConfig::default()
5597 };
5598 let containment = resolve_validator_containment_target(
5599 &cfg,
5600 crate::types::BackendKind::Claude,
5601 Path::new("/snap"),
5602 Path::new("/mission"),
5603 &[PathBuf::from("/repo")],
5604 false,
5605 "linux",
5606 false,
5607 Some(crate::sandbox_container::ContainerRuntime::Docker),
5608 None,
5609 )
5610 .expect("container resolves with a runtime");
5611 let sandbox = containment.sandbox.expect("the container wrap");
5612 assert_eq!(sandbox.backend, SandboxBackend::Container);
5613 assert!(
5614 sandbox.inputs.validator_read_deny_roots.is_empty(),
5615 "the container's mounts are the containment — no process-tier deny set"
5616 );
5617
5618 let mut off_container = off_cfg();
5619 off_container.provider = crate::types::SandboxProvider::Container;
5620 let containment = resolve_validator_containment_target(
5621 &off_container,
5622 crate::types::BackendKind::Claude,
5623 Path::new("/snap"),
5624 Path::new("/mission"),
5625 &[PathBuf::from("/repo")],
5626 false,
5627 "macos",
5628 false,
5629 None,
5630 None,
5631 )
5632 .expect("off+container still gets the mandatory process-tier wrap");
5633 assert_eq!(
5634 containment.sandbox.expect("a wrap applies").backend,
5635 SandboxBackend::Seatbelt,
5636 "provider:container with enforce:off documents 'no sandboxing'; the mandatory wrap is process-tier"
5637 );
5638 }
5639
5640 #[cfg(target_os = "macos")]
5645 #[test]
5646 fn validator_containment_macos_denies_real_checkout_reads() {
5647 use std::process::Command;
5648
5649 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
5650 if !sandbox_exec_can_apply() {
5651 return;
5652 }
5653 let (_dir, root, snapshot, mission) = validator_containment_fixture();
5654 let scratch = tempfile::tempdir().unwrap();
5655 let profile = generate_profile(&validator_containment_inputs(
5656 &root,
5657 &snapshot,
5658 &mission,
5659 scratch.path(),
5660 ));
5661 let profile_dir = tempfile::tempdir().unwrap();
5662 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
5663
5664 let read = |path: &Path| {
5665 Command::new("sandbox-exec")
5666 .arg("-f")
5667 .arg(&profile_path)
5668 .arg("/bin/cat")
5669 .arg(path)
5670 .status()
5671 .expect("failed to run sandbox-exec")
5672 };
5673 for denied in [
5675 root.join("src").join("secret.rs"),
5676 root.join("Cargo.toml"),
5677 root.join(".env"),
5678 ] {
5679 assert!(
5680 !read(&denied).success(),
5681 "read of the real tree must be denied: {}",
5682 denied.display()
5683 );
5684 }
5685 for denied in [
5690 root.join(".kranz").join("domain-terms.local"),
5691 root.join(".kranz")
5692 .join("hook-status")
5693 .join("m-x")
5694 .join("run-1.json"),
5695 mission.join("control").join("approve.json"),
5696 ] {
5697 assert!(
5698 !read(&denied).success(),
5699 "read of the sensitive .kranz runtime must be denied: {}",
5700 denied.display()
5701 );
5702 }
5703 let listing = Command::new("sandbox-exec")
5707 .arg("-f")
5708 .arg(&profile_path)
5709 .arg("/bin/ls")
5710 .arg(&root)
5711 .status()
5712 .expect("failed to run sandbox-exec");
5713 assert!(
5714 listing.success(),
5715 "the root listing stays open (names, never contents)"
5716 );
5717 assert!(
5719 !read(&root.join(".kranz").join("serve.token")).success(),
5720 "the authority read deny must survive the .kranz carve-out"
5721 );
5722 for allowed in [root.join(".git").join("HEAD"), snapshot.join("README.md")] {
5725 assert!(
5726 read(&allowed).success(),
5727 "read must keep working: {}",
5728 allowed.display()
5729 );
5730 }
5731 }
5732
5733 #[cfg(target_os = "macos")]
5739 #[test]
5740 fn validator_containment_macos_keeps_snapshot_writes_and_readonly_git() {
5741 use std::process::Command;
5742
5743 let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
5744 if !sandbox_exec_can_apply() {
5745 return;
5746 }
5747 let Some((_dir, root, snapshot, mission)) = validator_containment_git_fixture() else {
5748 return;
5749 };
5750 let scratch = tempfile::tempdir().unwrap();
5751 let profile = generate_profile(&validator_containment_inputs(
5752 &root,
5753 &snapshot,
5754 &mission,
5755 scratch.path(),
5756 ));
5757 let profile_dir = tempfile::tempdir().unwrap();
5758 let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
5759 let sh = |command: &str| {
5760 Command::new("sandbox-exec")
5761 .arg("-f")
5762 .arg(&profile_path)
5763 .arg("/bin/sh")
5764 .arg("-c")
5765 .arg(command)
5766 .status()
5767 .expect("failed to run sandbox-exec")
5768 };
5769
5770 for command in [
5773 format!("echo x >> {}", root.join("tracked.rs").display()),
5774 format!("echo x > {}", root.join("new.txt").display()),
5775 format!("echo x >> {}", mission.join("events.jsonl").display()),
5776 format!("git -C {} add -A", snapshot.display()),
5777 format!("git -C {} branch -f side HEAD", snapshot.display()),
5778 ] {
5779 assert!(!sh(&command).success(), "must be denied: {command}");
5780 }
5781 assert!(sh(&format!(
5783 "mkdir -p {0}/target && echo built > {0}/target/out && echo note > {0}/notes.txt",
5784 snapshot.display()
5785 ))
5786 .success());
5787 let git_log = Command::new("sandbox-exec")
5790 .arg("-f")
5791 .arg(&profile_path)
5792 .arg("git")
5793 .arg("-C")
5794 .arg(&snapshot)
5795 .arg("log")
5796 .arg("--oneline")
5797 .output()
5798 .expect("failed to run sandbox-exec");
5799 assert!(
5800 git_log.status.success(),
5801 "read-only git must work in the snapshot: {}",
5802 String::from_utf8_lossy(&git_log.stderr)
5803 );
5804 assert!(String::from_utf8_lossy(&git_log.stdout).contains("init"));
5805 assert!(sh(&format!("git -C {} status --porcelain", snapshot.display())).success());
5806 assert!(sh(&format!("git -C {} diff HEAD", snapshot.display())).success());
5807 assert!(sh(&format!("cat {}", snapshot.join("tracked.rs").display())).success());
5809 }
5810
5811 #[cfg(target_os = "linux")]
5815 #[test]
5816 fn validator_containment_linux_bwrap_denies_real_checkout_and_keeps_snapshot() {
5817 use std::process::Command;
5818
5819 if !bwrap_can_apply() {
5820 return;
5821 }
5822 let Some((dir, root, snapshot, mission)) = validator_containment_git_fixture() else {
5823 return;
5824 };
5825 let _env =
5829 crate::agent_env::EnvTestGuard::engage(&[("HOME", dir.path().to_str().unwrap())]);
5830 let scratch = tempfile::tempdir().unwrap();
5831 let inputs = validator_containment_inputs(&root, &snapshot, &mission, scratch.path());
5832 let run = |command: &str| {
5833 Command::new("bwrap")
5834 .args(
5835 bubblewrap_args(
5836 &inputs,
5837 Path::new("/bin/sh"),
5838 &["-c".to_string(), command.to_string()],
5839 )
5840 .unwrap(),
5841 )
5842 .output()
5843 .expect("failed to run bwrap")
5844 };
5845
5846 let shadowed = run(&format!(
5848 "cat {}",
5849 root.join("src").join("secret.rs").display()
5850 ));
5851 assert!(
5852 !shadowed.status.success(),
5853 "the tmpfs-shadowed source dir must not resolve: {}",
5854 String::from_utf8_lossy(&shadowed.stderr)
5855 );
5856 let masked = run(&format!("cat {}", root.join("tracked.rs").display()));
5859 assert!(
5860 !String::from_utf8_lossy(&masked.stdout).contains("tracked"),
5861 "the masked source file must not yield its content"
5862 );
5863 let authority_read = run(&format!(
5865 "cat {}",
5866 root.join(".kranz").join("serve.token").display()
5867 ));
5868 assert!(
5869 !String::from_utf8_lossy(&authority_read.stdout).contains("secret-token"),
5870 "the authority material must stay masked"
5871 );
5872 let git_head = run(&format!("cat {}", root.join(".git").join("HEAD").display()));
5874 assert!(git_head.status.success());
5875 let snap_read = run(&format!("cat {}", snapshot.join("tracked.rs").display()));
5876 assert!(
5877 String::from_utf8_lossy(&snap_read.stdout).contains("tracked"),
5878 "the snapshot's own copy reads fine"
5879 );
5880 for command in [
5883 format!("echo x >> {}", root.join("tracked.rs").display()),
5884 format!("echo x >> {}", mission.join("events.jsonl").display()),
5885 format!("git -C {} branch -f side HEAD", snapshot.display()),
5886 ] {
5887 assert!(!run(&command).status.success(), "must be denied: {command}");
5888 }
5889 assert!(
5890 run(&format!("echo built > {}/target-out", snapshot.display()))
5891 .status
5892 .success()
5893 );
5894 let git_log = run(&format!("git -C {} log --oneline", snapshot.display()));
5895 assert!(
5896 git_log.status.success(),
5897 "read-only git must work in the snapshot: {}",
5898 String::from_utf8_lossy(&git_log.stderr)
5899 );
5900 }
5901}
5902
5903#[cfg(test)]
5904#[path = "git_config_protection_tests.rs"]
5905mod git_config_protection_tests;