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