Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/persisting-pvisor/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion crates/persisting-pvisor/src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
29 changes: 22 additions & 7 deletions crates/persisting-pvisor/src/process.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -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};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -603,6 +615,7 @@ fn platform_launcher_command(
)
})?;
let sandbox_root = SandboxResources::create()?;
let network = network_isolation(spec);
let plan = rootless_plan(
spec,
invocation,
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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)?;

Expand Down Expand Up @@ -762,6 +776,7 @@ fn rootless_plan(
program: &Path,
root: PathBuf,
attestation: PathBuf,
network: NetworkIsolation,
) -> std::io::Result<SandboxPlan> {
let cwd = invocation
.cwd
Expand Down Expand Up @@ -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,
})
}
Expand Down
78 changes: 56 additions & 22 deletions crates/persisting-pvisor/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,7 +69,7 @@ pub(crate) struct SandboxPlan {
pub attestation: PathBuf,
pub read_only: Vec<PathBuf>,
pub read_write: Vec<PathBuf>,
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)]
Expand All @@ -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.
Expand Down Expand Up @@ -99,7 +113,7 @@ fn run_internal() -> anyhow::Result<()> {
.context("rootless sandbox invocation is missing the Agent executable")?;
let arguments = arguments.collect::<Vec<_>>();

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 {
Expand Down Expand Up @@ -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"
},
);
}

Expand Down Expand Up @@ -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"
},
);
}

Expand Down Expand Up @@ -321,7 +343,7 @@ pub(crate) fn restrict_krun_runner(
) -> anyhow::Result<u32> {
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",
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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};

Expand All @@ -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());
Expand All @@ -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\
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -624,7 +651,7 @@ fn canonical_seatbelt_paths(paths: &[PathBuf], kind: &str) -> std::io::Result<Ve
}

#[cfg(target_os = "linux")]
fn enter_rootless_namespaces(deny_network: bool) -> 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 {
Expand All @@ -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))?;
}
Expand Down Expand Up @@ -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);
}
}
14 changes: 11 additions & 3 deletions crates/persisting-pvisor/tests/macos_safe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand 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",
Expand All @@ -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])
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions docs/src/en/pvisor/guides/network.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions docs/src/zh/pvisor/guides/network.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading