diff --git a/crates/persisting-pvisor/src/cli/mod.rs b/crates/persisting-pvisor/src/cli/mod.rs index ca0e1dbc..ab52f328 100644 --- a/crates/persisting-pvisor/src/cli/mod.rs +++ b/crates/persisting-pvisor/src/cli/mod.rs @@ -19,7 +19,7 @@ const ROOT_LONG_ABOUT: &str = "Foreground Agent Run manager: execute, control, G const ROOT_ABOUT: &str = "Foreground Agent Run manager with Seatbelt isolation and reviewable workspaces"; #[cfg(target_os = "macos")] -const ROOT_LONG_ABOUT: &str = "Foreground Agent Run manager: execute, control, Gateway, and OverlayFS.\n\nOn macOS, host runs use safe-best-effort macFUSE workspace views and Seatbelt confinement when supported. Full-disk reads remain available for local toolchain compatibility. `--overlaynet-deny-all` also blocks IP and ambient host Unix sockets while retaining Run-local IPC."; +const ROOT_LONG_ABOUT: &str = "Foreground Agent Run manager: execute, control, Gateway, and OverlayFS.\n\nOn macOS, host runs use safe-best-effort macFUSE workspace views and Seatbelt confinement when supported. Full-disk reads remain available for local toolchain compatibility. `--overlaynet-deny-all` also blocks non-loopback IP and ambient host Unix sockets while retaining loopback proxy access and Run-local IPC."; #[cfg(not(any(target_os = "linux", target_os = "macos")))] const ROOT_ABOUT: &str = "Foreground Agent Run manager with staged, reviewable workspaces"; diff --git a/crates/persisting-pvisor/src/cli/run.rs b/crates/persisting-pvisor/src/cli/run.rs index c190e36f..e7a3256d 100644 --- a/crates/persisting-pvisor/src/cli/run.rs +++ b/crates/persisting-pvisor/src/cli/run.rs @@ -133,7 +133,7 @@ pub(super) const RUN_COMMAND_LONG_ABOUT: &str = MACOS_RUN_COMMAND_LONG_ABOUT; // Compile the macOS description in tests on every platform so Linux CI also // checks its safety disclosures instead of leaving them to the macOS shard. #[cfg(any(target_os = "macos", test))] -const MACOS_RUN_COMMAND_LONG_ABOUT: &str = "Execute one Agent Run under pVisor management. Host execution uses safe-best-effort isolation when supported by the system.\n\nOn macOS, staged workspace views use macFUSE and Seatbelt confines writes when available. Full-disk reads remain ambient; selective network policies remain cooperative. With --overlaynet-deny-all, Seatbelt blocks IP traffic and ambient host Unix sockets while permitting Run-scoped Unix IPC.\n\nUnavailable isolation capabilities are reported as warnings in best-effort mode. With --strict, insufficient isolation guarantees cause the Run to fail before Agent execution."; +const MACOS_RUN_COMMAND_LONG_ABOUT: &str = "Execute one Agent Run under pVisor management. Host execution uses safe-best-effort isolation when supported by the system.\n\nOn macOS, staged workspace views use macFUSE and Seatbelt confines writes when available. Full-disk reads remain ambient; selective network policies remain cooperative. With --overlaynet-deny-all, Seatbelt blocks non-loopback IP traffic and ambient host Unix sockets while permitting loopback proxy access and Run-scoped Unix IPC.\n\nUnavailable isolation capabilities are reported as warnings in best-effort mode. With --strict, insufficient isolation guarantees cause the Run to fail before Agent execution."; #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub(super) const RUN_COMMAND_ABOUT: &str = "Execute one Agent Run under pVisor management"; diff --git a/crates/persisting-pvisor/src/process.rs b/crates/persisting-pvisor/src/process.rs index 735c0982..e2544fd5 100644 --- a/crates/persisting-pvisor/src/process.rs +++ b/crates/persisting-pvisor/src/process.rs @@ -1,6 +1,6 @@ use crate::executor::{AttemptContext, RunExecutor}; #[cfg(any(target_os = "linux", target_os = "macos"))] -use crate::sandbox::INTERNAL_SANDBOX_ARG; +use crate::sandbox::{INTERNAL_SANDBOX_ARG, NetworkIsolation}; #[cfg(target_os = "macos")] use crate::sandbox::{MACOS_SANDBOX_EXEC, SEATBELT_ATTESTATION, SeatbeltPlan, seatbelt_profile}; #[cfg(target_os = "linux")] @@ -17,7 +17,9 @@ use persisting_agentctl::{FilesystemAccess, NetworkCapability}; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::path::Path; use std::path::PathBuf; -use std::process::{Command as StdCommand, Stdio}; +#[cfg(target_os = "linux")] +use std::process::Command as StdCommand; +use std::process::Stdio; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::process::{Child, Command}; @@ -350,6 +352,15 @@ fn stdio(mode: StdioMode) -> Stdio { } } +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn network_isolation(spec: &RunSpec) -> NetworkIsolation { + if matches!(spec.capabilities.network, NetworkCapability::Deny) { + NetworkIsolation::LoopbackOnly + } else { + NetworkIsolation::Ambient + } +} + fn resolve_host_program(program: &str) -> std::path::PathBuf { if program.contains(std::path::MAIN_SEPARATOR) { return program.into(); @@ -566,6 +577,7 @@ fn apply_resource_limits(limits: &ResourceLimits) -> std::io::Result<()> { }}; } + #[cfg(not(target_os = "macos"))] if let Some(bytes) = limits.memory_bytes { set_limit!(libc::RLIMIT_AS, bytes); } @@ -603,6 +615,7 @@ fn platform_launcher_command( ) })?; let sandbox_root = SandboxResources::create()?; + let network = network_isolation(spec); let plan = rootless_plan( spec, invocation, @@ -615,6 +628,7 @@ fn platform_launcher_command( .attestation_path() .expect("created rootless attestation") .to_owned(), + network, )?; let encoded = serde_json::to_string(&plan).map_err(std::io::Error::other)?; let mut command = Command::new(launcher); @@ -682,8 +696,8 @@ fn platform_launcher_command( writable_paths.push(path); } - let deny_network = matches!(spec.capabilities.network, NetworkCapability::Deny); - let (allowed_unix_sockets, local_socket_roots) = if deny_network { + let network = network_isolation(spec); + let (allowed_unix_sockets, local_socket_roots) = if network.is_loopback_only() { ( invocation .env @@ -707,14 +721,14 @@ fn platform_launcher_command( &writable_paths, &allowed_unix_sockets, &local_socket_roots, - deny_network, + network, )?; let plan = SeatbeltPlan { attestation: resources .attestation_path() .expect("created Seatbelt attestation") .to_owned(), - deny_network, + network, }; let encoded = serde_json::to_string(&plan).map_err(std::io::Error::other)?; @@ -762,6 +776,7 @@ fn rootless_plan( program: &Path, root: PathBuf, attestation: PathBuf, + network: NetworkIsolation, ) -> std::io::Result { let cwd = invocation .cwd @@ -840,7 +855,7 @@ fn rootless_plan( attestation, read_only, read_write, - deny_network: matches!(spec.capabilities.network, NetworkCapability::Deny), + network, process_limit: spec.runtime.resource_limits.processes, }) } diff --git a/crates/persisting-pvisor/src/sandbox.rs b/crates/persisting-pvisor/src/sandbox.rs index 32928de9..d092db7a 100644 --- a/crates/persisting-pvisor/src/sandbox.rs +++ b/crates/persisting-pvisor/src/sandbox.rs @@ -47,6 +47,20 @@ const LANDLOCK_ACCESS_FS_V3: u64 = (1 << 15) - 1; const LANDLOCK_ACCESS_FS_READ: u64 = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum NetworkIsolation { + Ambient, + LoopbackOnly, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl NetworkIsolation { + pub(crate) const fn is_loopback_only(self) -> bool { + matches!(self, Self::LoopbackOnly) + } +} + #[cfg(target_os = "linux")] #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct SandboxPlan { @@ -55,7 +69,7 @@ pub(crate) struct SandboxPlan { pub attestation: PathBuf, pub read_only: Vec, pub read_write: Vec, - pub deny_network: bool, + pub network: NetworkIsolation, /// Applied after the private PID namespace is initialized so the trusted /// launcher itself can still create its init/reaper process. #[serde(default)] @@ -66,7 +80,7 @@ pub(crate) struct SandboxPlan { #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct SeatbeltPlan { pub attestation: PathBuf, - pub deny_network: bool, + pub network: NetworkIsolation, } /// Enter the hidden launcher when the first argument is the internal marker. @@ -99,7 +113,7 @@ fn run_internal() -> anyhow::Result<()> { .context("rootless sandbox invocation is missing the Agent executable")?; let arguments = arguments.collect::>(); - enter_rootless_namespaces(plan.deny_network) + enter_rootless_namespaces(plan.network) .context("initialize rootless user and mount namespaces")?; enter_child_pid_namespace().context("initialize private PID namespace")?; if let Some(limit) = plan.process_limit { @@ -141,7 +155,11 @@ fn run_internal() -> anyhow::Result<()> { std::env::set_var("PERSISTING_SANDBOX_USER_NAMESPACE", "1"); std::env::set_var( "PERSISTING_SANDBOX_NETWORK", - if plan.deny_network { "deny" } else { "ambient" }, + if plan.network.is_loopback_only() { + "deny" + } else { + "ambient" + }, ); } @@ -199,7 +217,11 @@ fn run_internal() -> anyhow::Result<()> { std::env::set_var("PERSISTING_SANDBOX_FILESYSTEM", "seatbelt-write"); std::env::set_var( "PERSISTING_SANDBOX_NETWORK", - if plan.deny_network { "deny" } else { "ambient" }, + if plan.network.is_loopback_only() { + "deny" + } else { + "ambient" + }, ); } @@ -321,7 +343,7 @@ pub(crate) fn restrict_krun_runner( ) -> anyhow::Result { use anyhow::Context; - enter_rootless_namespaces(true) + enter_rootless_namespaces(NetworkIsolation::LoopbackOnly) .context("initialize libkrun user, mount, and network namespaces")?; let mut read_only = [ "/usr/lib", @@ -349,7 +371,7 @@ pub(crate) fn restrict_krun_runner( attestation: PathBuf::from("/dev/null"), read_only, read_write, - deny_network: true, + network: NetworkIsolation::LoopbackOnly, process_limit: None, }; let abi = install_landlock(&plan).context("install libkrun Landlock policy")?; @@ -468,15 +490,15 @@ fn run_internal() -> anyhow::Result<()> { /// Generate a compatibility-oriented Seatbelt profile. /// /// Reads remain ambient so ordinary developer toolchains keep working. Every -/// pathname write outside `writable_paths` is denied by Seatbelt. A deny-all -/// network Run instead starts from `deny default` and admits only the exact -/// Run-scoped Unix sockets plus sockets rooted in Run-owned directories. +/// pathname write outside `writable_paths` is denied by Seatbelt. A +/// network-isolated Run starts from `deny default` and admits loopback IP, +/// exact Run-scoped Unix sockets, and sockets rooted in Run-owned directories. #[cfg(target_os = "macos")] pub(crate) fn seatbelt_profile( writable_paths: &[PathBuf], allowed_unix_sockets: &[PathBuf], local_socket_roots: &[PathBuf], - deny_network: bool, + network: NetworkIsolation, ) -> std::io::Result<(String, Vec<(String, PathBuf)>)> { use std::io::{Error, ErrorKind}; @@ -502,7 +524,7 @@ pub(crate) fn seatbelt_profile( parameters.push((key, path.clone())); } - if deny_network { + if network.is_loopback_only() { let allowed_unix_sockets = canonical_seatbelt_paths(allowed_unix_sockets, "Unix socket")?; let local_socket_roots = canonical_seatbelt_paths(local_socket_roots, "local socket root")?; parameters.reserve(allowed_unix_sockets.len() + local_socket_roots.len()); @@ -513,11 +535,12 @@ pub(crate) fn seatbelt_profile( parameters.push((format!("PVISOR_SOCKET_ROOT_{index}"), path.clone())); } - // Deny by default for a genuine no-network Run. The allowlist below is + // Deny by default for a network-isolated Run. The allowlist below is // intentionally small and mirrors the system services required by // shells, language runtimes, PTYs, and read-only preferences. Socket // operations are admitted only so the filtered denies below can retain - // Run-local Unix IPC while rejecting IP and ambient host Unix sockets. + // Run-local Unix IPC while rejecting non-loopback IP and ambient host + // Unix sockets. let mut profile = String::from( "(version 1)\n\ (deny default)\n\ @@ -548,7 +571,11 @@ pub(crate) fn seatbelt_profile( (allow network*)\n\ (deny network-bind (local ip))\n\ (deny network-inbound (local ip))\n\ - (deny network-outbound (remote ip))\n", + (deny network-outbound\n\ + (require-all\n\ + (remote ip)\n\ + (require-not (remote ip \"localhost:*\"))))\n\ + (allow network-outbound (remote ip \"localhost:*\"))\n", ); profile.push_str("(allow file-write*\n"); for index in 0..writable_paths.len() { @@ -624,7 +651,7 @@ fn canonical_seatbelt_paths(paths: &[PathBuf], kind: &str) -> std::io::Result std::io::Result<()> { +fn enter_rootless_namespaces(network: NetworkIsolation) -> std::io::Result<()> { let uid = unsafe { libc::getuid() }; let gid = unsafe { libc::getgid() }; if unsafe { libc::unshare(libc::CLONE_NEWUSER) } != 0 { @@ -651,10 +678,10 @@ fn enter_rootless_namespaces(deny_network: bool) -> std::io::Result<()> { if unsafe { libc::unshare(libc::CLONE_NEWNS) } != 0 { return Err(namespace_stage_error("unshare mount namespace")); } - if deny_network && unsafe { libc::unshare(libc::CLONE_NEWNET) } != 0 { + if network.is_loopback_only() && unsafe { libc::unshare(libc::CLONE_NEWNET) } != 0 { return Err(namespace_stage_error("unshare network namespace")); } - if deny_network { + if network.is_loopback_only() { bring_loopback_up() .map_err(|error| with_io_context("enable network namespace loopback", error))?; } @@ -1229,15 +1256,22 @@ mod tests { .tempdir() .unwrap(); let canonical = temporary.path().canonicalize().unwrap(); - let (profile, parameters) = - seatbelt_profile(&[temporary.path().to_owned()], &[], &[], true).unwrap(); + let (profile, parameters) = seatbelt_profile( + &[temporary.path().to_owned()], + &[], + &[], + NetworkIsolation::LoopbackOnly, + ) + .unwrap(); assert!(!profile.contains(canonical.to_str().unwrap())); assert_eq!(parameters, [("PVISOR_WRITABLE_0".into(), canonical)]); assert!(profile.contains("(deny default)")); - assert!(!profile.contains("(allow network-outbound")); + assert!(profile.contains("(remote ip \"localhost:*\")")); + assert!(profile.contains("(allow network-outbound (remote ip \"localhost:*\"))")); - let error = seatbelt_profile(&[PathBuf::from("/")], &[], &[], false).unwrap_err(); + let error = seatbelt_profile(&[PathBuf::from("/")], &[], &[], NetworkIsolation::Ambient) + .unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); } } diff --git a/crates/persisting-pvisor/tests/macos_safe.rs b/crates/persisting-pvisor/tests/macos_safe.rs index b953a315..93d69917 100644 --- a/crates/persisting-pvisor/tests/macos_safe.rs +++ b/crates/persisting-pvisor/tests/macos_safe.rs @@ -3,6 +3,7 @@ use persisting_agentctl::IsolationKind; use persisting_pvisor::RunBundle; use std::fs; +use std::net::TcpListener; use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf}; use std::process::Command; @@ -163,11 +164,14 @@ fn deny_all_blocks_ip_and_host_unix_sockets_on_macos() { let workspace = temporary.path().join("workspace"); let run_home = temporary.path().join("runs"); let outside_socket = temporary.path().join("host.sock"); + let loopback_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let loopback_port = loopback_listener.local_addr().unwrap().port(); fs::create_dir(&workspace).unwrap(); let _listener = UnixListener::bind(&outside_socket).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_pvisor")) .env("PERSISTING_RUN_HOME", &run_home) + .env("LOOPBACK_PORT", loopback_port.to_string()) .args([ "run", "--overlaynet-deny-all", @@ -176,6 +180,7 @@ fn deny_all_blocks_ip_and_host_unix_sockets_on_macos() { "--overlayfs-compose", ]) .arg(&workspace) + .args(["--pass-env", "LOOPBACK_PORT"]) .args([ "--", "/usr/bin/python3", @@ -190,10 +195,13 @@ agentctl.close() try: inet = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - inet_code = inet.connect_ex(("127.0.0.1", 9)) + inet_code = inet.connect_ex(("192.0.2.1", 9)) except PermissionError as error: inet_code = error.errno +loopback = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +loopback_code = loopback.connect_ex(("127.0.0.1", int(os.environ["LOOPBACK_PORT"]))) + try: host = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) host_code = host.connect_ex(sys.argv[1]) @@ -203,8 +211,8 @@ except PermissionError as error: local = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) local.bind(os.path.join(os.environ["TMPDIR"], "local.sock")) local.close() -print(inet_code, host_code) -raise SystemExit(0 if inet_code in denied and host_code in denied else 1)"#, +print(inet_code, loopback_code, host_code) +raise SystemExit(0 if inet_code in denied and loopback_code == 0 and host_code in denied else 1)"#, ]) .arg(&outside_socket) .output() diff --git a/docs/src/en/pvisor/guides/network.md b/docs/src/en/pvisor/guides/network.md index a4025488..cbf23bca 100644 --- a/docs/src/en/pvisor/guides/network.md +++ b/docs/src/en/pvisor/guides/network.md @@ -172,8 +172,8 @@ The following paths are outside that cooperative host/container boundary: Consequently, a host/container cooperative-proxy Run reports `safety.network_non_bypassable = false`. When direct network access must be blocked, use `pvisor -- --overlaynet-deny-all`: Linux adds a private -network namespace; macOS blocks IP and ambient host Unix sockets with Seatbelt, -retaining only the exact AgentCtl and Run-local IPC. Container Runs can instead +network namespace; macOS blocks non-loopback IP and ambient host Unix sockets with Seatbelt, +while retaining loopback proxy access and the exact AgentCtl and Run-local IPC. Container Runs can instead use `--container-network none`. Selective allow/deny rules remain cooperative on both native host paths. The VM executor defaults to `[overlaynet] mode = "auto"`, which supplies DHCP, synthetic DNS, and policy-controlled IPv4 TCP; diff --git a/docs/src/zh/pvisor/guides/network.md b/docs/src/zh/pvisor/guides/network.md index 15b4609d..3b700080 100644 --- a/docs/src/zh/pvisor/guides/network.md +++ b/docs/src/zh/pvisor/guides/network.md @@ -157,8 +157,8 @@ IP/CIDR 策略时,应使用能返回具体地址的 resolver。 因此 host/container cooperative-proxy Run 会报告 `safety.network_non_bypassable = false`。如果必须 彻底阻止直接联网,使用 `pvisor -- --overlaynet-deny-all`:Linux 会创建私有 -network namespace;macOS 会用 Seatbelt 阻断 IP 与宿主 ambient Unix socket,只保留精确的 -AgentCtl 和 Run 私有目录内 IPC。Container Run 也可以使用 `--container-network none`。 +network namespace;macOS 会用 Seatbelt 阻断非 loopback IP 与宿主 ambient Unix socket,同时保留 +loopback proxy、精确的 AgentCtl 和 Run 私有目录内 IPC。Container Run 也可以使用 `--container-network none`。 两种本地 host 路径上的 selective allow/deny 仍是协作式。VM executor 默认使用 `[overlaynet] mode = "auto"`,由 smoltcp 提供 DHCP、合成 DNS 与受策略控制的 IPv4 TCP; `mode = "off"` 会让 VM 离线。Gateway capture 通过 guest 虚拟路由器暴露;container diff --git a/docs/src/zh/pvisor/reference/cases.md b/docs/src/zh/pvisor/reference/cases.md index c8ea43ee..917ec63f 100644 --- a/docs/src/zh/pvisor/reference/cases.md +++ b/docs/src/zh/pvisor/reference/cases.md @@ -48,10 +48,9 @@ python3 scripts/run-pvisor-cases.py --pvisor target/release/pvisor --report targ 断言中的 `bundle_expect` 等函数由脚本提供,用于读取本次运行的 `run-bundle.json` 和 `run.json`;它们不是 pVisor 命令。 -case 注释中的 `requires` 只描述建议的运行环境,不会导致 case 被跳过;脚本总是 -使用默认值执行,并把缺少 KVM、rootfs、Lance 或 OCI runtime 的真实错误记录为 -`FAIL`。`--keep` 保留现场;`--strict-skips` 为兼容旧用法保留,但正常情况下不会产生 -`SKIP`。Linux 未提供 rootfs 时,目录 rootfs case 使用宿主 `/` 进行 smoke test, +case 注释中的 `requires` 描述运行环境;缺少前置条件时脚本将 case 标为 `SKIP`,并在 +`--run-unavailable` 下强制执行以便记录真实错误。`--keep` 保留现场; +`--strict-skips` 可将跳过视为失败。Linux 未提供 rootfs 时,目录 rootfs case 使用宿主 `/` 进行 smoke test, 这只能验证流程,不能代表独立的 guest rootfs,也不应作为生产隔离边界。 | 测试资源 | 脚本配置 | @@ -185,11 +184,11 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 用途:要求严格执行能力检查。`--strict` 不接受所请求能力缺少强制执行证据;这里同时要求禁止网络。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:当前 host 执行路径在启动 Agent 前拒绝请求,并列出缺少执行证据的能力。此例验证拒绝路径,不代表 strict 在所有 executor 上都不可用。 - + ```bash pvisor --strict --overlaynet-deny-all -- /bin/true @@ -327,12 +326,10 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 用途:为持久 stage 请求 1GiB 的总大小限制。它限制的是 stage 总量,和 B02 的单个文件大小不是同一个概念。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:stage 成功建立并保存在指定路径。此例只验证参数可用和目录建立;当前产物未记录该上限,也未在此例中尝试写满 stage。 - - ```bash pvisor --stage /tmp/pvisor-cases/limited-stage --max-stage-size 1GiB -- /bin/true ``` @@ -345,7 +342,7 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 ```bash bundle_expect filesystem.state staged bundle_expect safety.filesystem_changes_staged true - record_expect storage "$PVISOR_CASE_ROOT/limited-stage" + record_expect storage "$(realpath "$PVISOR_CASE_ROOT/limited-stage")" ``` @@ -360,12 +357,10 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 用途:把本次运行的文件改动放进一个保留的 stage。命令在 workspace 里创建 `result.txt`。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:原 workspace 没有 `result.txt`;变更清单中出现该文件,指定 stage 内保留 `run-bundle.json`,便于之后查看。 - - ```bash pvisor --stage /tmp/pvisor-cases/stage-keep -- /bin/sh -c 'printf changed > result.txt' ``` @@ -378,7 +373,7 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 ```bash bundle_expect filesystem.state staged bundle_contains filesystem.changes result.txt - bundle_expect safety.filesystem_non_bypassable true + bundle_expect safety.filesystem_write_non_bypassable true test ! -e result.txt test -f "$PVISOR_CASE_ROOT/stage-keep/run-bundle.json" ``` @@ -391,12 +386,10 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 用途:运行一次不需要保留改动的任务。`--stage drop` 自动选择系统临时目录,退出后删除该目录。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:命令成功,日志中给出的临时存储目录已删除,原 workspace 也没有新建的文件。 - - ```bash pvisor --stage drop -- /bin/sh -c 'printf changed > result.txt' ``` @@ -421,12 +414,10 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 用途:自己选择临时 stage 路径,但仍要求运行结束后自动删除。示例先创建一个空目录。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:运行完成后 `stage-drop` 目录不存在。请使用专用空目录,不要指定含有用户文件的目录。 - - ```bash mkdir -p /tmp/pvisor-cases/stage-drop pvisor --stage drop:/tmp/pvisor-cases/stage-drop -- /bin/true @@ -477,7 +468,7 @@ Linux host stage 示例需要可用的 user/mount namespace。VM 示例需要可 用途:比较 workspace 写入和 sandbox 临时目录写入。前者用于保留任务改动,后者只供本次运行临时使用。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 可用;macOS Seatbelt 不提供此例要求的 whole-rootfs/tmpfs 隔离。 预期:workspace 的改动出现在 stage,宿主 workspace 和宿主 `/tmp` 均不出现新文件。这里不验证 workspace 以外普通 rootfs 路径的持久化。 @@ -511,12 +502,10 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 用途:把宿主的两个目录依次叠加到工作区视图,并指定 Agent 看到的路径。`directory` 选择目录后端,`manual` 表示退出后不自动应用改动。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:记录的目标为 `view`,从顶层到底层依次为 `layer`、`base`、当前 workspace。目录为空,因此此例检查配置顺序,不检查同名文件覆盖内容。 - - ```bash mkdir -p /tmp/pvisor-cases/base /tmp/pvisor-cases/layer "$PWD/view" pvisor \ @@ -536,9 +525,9 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace ```bash bundle_expect filesystem.state staged - record_expect overlay_lowers.0 "$PVISOR_CASE_ROOT/layer" - record_expect overlay_lowers.1 "$PVISOR_CASE_ROOT/base" - record_expect overlay_lowers.2 "$PVISOR_CASE_WORKSPACE" + record_expect overlay_lowers.0 "$(realpath "$PVISOR_CASE_ROOT/layer")" + record_expect overlay_lowers.1 "$(realpath "$PVISOR_CASE_ROOT/base")" + record_expect overlay_lowers.2 "$(realpath "$PVISOR_CASE_WORKSPACE")" ``` @@ -549,7 +538,11 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 用途:显式选择 host executor,观察当前系统上的隔离类型。 - 预期:在支持 user namespace 的 Linux 上,记录为 `rootless_process`。本例断言针对该环境;若系统正常降级到 host process,这组断言会失败,需要结合 warning 判断。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 + + 预期:Linux 记录为 `rootless_process`,macOS 记录为 `sandboxed_process`;两者都不应降级为 host process。 + + ```bash pvisor --executor host -- /bin/true @@ -562,7 +555,11 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace ```bash bundle_expect run.executor.kind process - bundle_expect run.executor.isolation rootless_process + if [ "$(uname -s)" = "Darwin" ]; then + bundle_expect run.executor.isolation sandboxed_process + else + bundle_expect run.executor.isolation rootless_process + fi bundle_expect safety.host_process false ``` @@ -574,7 +571,7 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 用途:观察启用 stage 后子进程的 cwd 和 procfs 路径。三条命令的输出保存到 `views.txt`。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 可用;macOS 不支持此例的 procfs/mount namespace 路径隐藏语义。 预期:cwd 指向 stage 的 merged 目录,输出中不出现原 workspace 路径。此例只检查路径显示,不证明所有原路径或继承 FD 访问都已被禁止。 @@ -1014,13 +1011,13 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 建议场景:适合配置出站网络、代理访问或禁止网络的任务。 - 用途:验证禁止网络后,清除常见代理变量仍不能访问外网。需要宿主安装 curl;命令故意发起网络请求。 + 用途:验证禁止网络后,清除常见代理变量仍不能访问外网。Linux 使用 network namespace,macOS 使用 Seatbelt;需要宿主安装 curl,命令故意发起网络请求。 - 准备:Linux user/mount namespace 可用;安装 curl。 + 准备:安装 curl。 预期:命令失败,结果记录 no-network 和不可绕过边界。外网自身不可用也会使 curl 失败,因此本例不能单独证明隔离有效。 - + ```bash pvisor --overlaynet-deny-all -- /bin/sh -c \ @@ -1103,12 +1100,10 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 用途:为需要模型请求记录的 Agent 配置 Gateway。示例设置路由、管理监听端口、完整记录级别、会话头、诊断输出和 Markdown 投影,并保留 stage。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:Gateway 与 stage 成功建立。示例上游是占位地址,`/bin/true` 不发送模型请求;此例不验证对话内容。管理监听地址与运行记录中的 `gateway_listen` 不是同一个服务地址。 - - ```bash pvisor \ --stage /tmp/pvisor-cases/gateway-stage \ @@ -1221,12 +1216,10 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 用途:执行已准备好的 JSON RunSpec,并把结果原子写入指定文件。手工运行前准备包含 run_id、agent 和 process invocation 的 `run-spec.json`;脚本预置的是运行 `/bin/true` 的 `case-i02`。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:运行名称为 `case-i02`,`run-result.json` 非空。该委托路径当前只支持 host executor,不套用普通 Run 的 rootless safe profile;不要把此例视为隔离模式示例。 - - ```bash pvisor --spec ./run-spec.json --result-file ./run-result.json --stage ./delegated-stage ``` @@ -1277,12 +1270,10 @@ D01 讲视图层组合,D02/D03 讲 host executor 的默认隔离和 workspace 用途:组合使用 host stage、禁止网络、输出采集、JSON 事件和资源限制。命令在隔离视图中写入一个结果文件。 - 准备:Linux user/mount namespace 可用。 + 准备:Linux user/mount namespace 或 macOS Seatbelt 可用。 预期:原 workspace 不变;stage 记录 `result.txt`,结果保存 stdout 和资源请求,事件写入指定 JSONL 文件,网络标记为禁止连接。 - - ```bash pvisor --name host-full \ --executor host \ diff --git a/scripts/run-pvisor-cases.py b/scripts/run-pvisor-cases.py index f49516b0..c05e6a82 100755 --- a/scripts/run-pvisor-cases.py +++ b/scripts/run-pvisor-cases.py @@ -331,17 +331,51 @@ def container_runtime() -> str | None: def requirement_reason(requirement: str) -> str | None: """Return None when the prerequisite holds, else a human-readable reason. - Requirements are descriptive metadata only. Cases are intentionally not - skipped: defaults are rendered and the real pVisor/runtime result is - reported, including missing KVM, rootfs, images, or optional features. + ``--run-unavailable`` intentionally bypasses these checks for diagnostics. """ environment = os.environ - # Requirements document useful capabilities, but never suppress a case. - # The runner always executes with safe defaults so the report contains the - # real pVisor/runtime error instead of hiding it as SKIP. + if requirement == "linux" and sys.platform != "linux": + return "requires Linux" + if requirement == "kvm" and not Path("/dev/kvm").exists(): + return "requires /dev/kvm" + if requirement == "rootless" and not rootless_available(): + return "requires Linux user/mount namespaces" + if requirement == "curl" and shutil.which("curl") is None: + return "requires curl" + if requirement == "python3" and not executable(sys.executable): + return "requires Python 3" + if requirement == "rootfs": + rootfs = environment.get("PVISOR_CASE_ROOTFS") + if rootfs and not Path(rootfs).is_dir(): + return f"rootfs is not a directory: {rootfs}" + if sys.platform != "linux" and not rootfs: + return "requires a Linux rootfs via PVISOR_CASE_ROOTFS" + if requirement == "firmware": + firmware = environment.get("PVISOR_CASE_FIRMWARE") + cache = Path.home() / ".cache/persisting/pvisor/firmware/5.5.0/linux-x86_64" + if sys.platform == "darwin": + cache = Path.home() / "Library/Caches/persisting/pvisor/firmware/5.5.0/macos-aarch64" + if not (firmware and Path(firmware).is_dir()) and not cache.is_dir(): + return "requires libkrunfw (set PVISOR_CASE_FIRMWARE)" + if requirement in {"container", "container-runtime"} and not container_runtime(): + return "requires crun or runc" + if requirement == "agent" and not environment.get("PVISOR_CASE_AGENT"): + return "requires PVISOR_CASE_AGENT" + if requirement == "lance" and environment.get("PVISOR_CASE_LANCE") != "1": + return "requires PVISOR_CASE_LANCE=1" if requirement in { - "linux", "kvm", "rootless", "curl", "python3", "rootfs", "image", - "firmware", "container", "container-runtime", "agent", "lance", + "curl", + "firmware", + "image", + "kvm", + "linux", + "python3", + "rootfs", + "rootless", + "agent", + "lance", + "container", + "container-runtime", }: return None return f"unknown requirement {requirement!r}" @@ -355,15 +389,16 @@ def free_loopback_port() -> int: def prepare_workspace(workspace: Path) -> None: workspace.mkdir(parents=True) + true_program = "/usr/bin/true" if sys.platform == "darwin" else "/bin/true" (workspace / "pvisor.toml").write_text( - '[run]\ncommand = ["/bin/true"]\n', encoding="utf-8" + f'[run]\ncommand = ["{true_program}"]\n', encoding="utf-8" ) (workspace / "spec-without-extension").write_text( - '[run]\ncommand = ["/bin/true"]\n', encoding="utf-8" + f'[run]\ncommand = ["{true_program}"]\n', encoding="utf-8" ) (workspace / "run-spec.json").write_text( - '{"run_id":"case-i02","agent":{"name":"case-i02"},' - '"invocation":{"kind":"process","program":"/bin/true"}}\n', + f'{{"run_id":"case-i02","agent":{{"name":"case-i02"}},' + f'"invocation":{{"kind":"process","program":"{true_program}"}}}}\n', encoding="utf-8", ) @@ -377,6 +412,7 @@ def render_command(code: str, case_root: Path, pvisor: Path, ports: dict[str, st is meaningful in the first place. """ environment = os.environ + true_program = "/usr/bin/true" if sys.platform == "darwin" else "/bin/true" default_rootfs = "/" if sys.platform == "linux" else "/path/to/rootfs" default_firmware = "/path/to/libkrunfw" firmware_cache = Path.home() / ".cache/persisting/pvisor/firmware/5.5.0" @@ -385,10 +421,11 @@ def render_command(code: str, case_root: Path, pvisor: Path, ports: dict[str, st replacements = { "/tmp/pvisor-cases": str(case_root), "./target/release/pvisor": str(pvisor), + "/bin/true": true_program, "/path/to/rootfs": environment.get("PVISOR_CASE_ROOTFS", default_rootfs), "/path/to/image": environment.get("PVISOR_CASE_IMAGE", "ubuntu:latest"), "/path/to/libkrunfw": environment.get("PVISOR_CASE_FIRMWARE", default_firmware), - "/usr/local/bin/agent": environment.get("PVISOR_CASE_AGENT", "/bin/true"), + "/usr/local/bin/agent": environment.get("PVISOR_CASE_AGENT", true_program), "alpine:latest": environment.get("PVISOR_CASE_CONTAINER_IMAGE", "ubuntu:latest"), **ports, }