diff --git a/crates/thalyx-cli/src/exec.rs b/crates/thalyx-cli/src/exec.rs index f3fbdc5..a6b0fc0 100644 --- a/crates/thalyx-cli/src/exec.rs +++ b/crates/thalyx-cli/src/exec.rs @@ -1239,7 +1239,16 @@ fn run_check( // Nothing added to its environment. A check that names a program // has named a program, and telling it where a Rust toolchain is // would be this verb deciding what somebody else's binary is for. - let outcome = run_confined(asked, program, arguments, &[], &[], &[], metrics); + let outcome = run_confined( + asked, + program, + arguments, + &[], + &[], + &[], + thalyx_sandbox::profile::MODULE_STANDARD, + metrics, + ); CheckRecord { key: String::new(), check: format!("program `{program}`"), @@ -1275,6 +1284,10 @@ struct Ran { /// arrives as [`Verdict::NotProven`]: **a check that could not run is not a /// check that passed.** This container is such a machine, which is why the /// tests that exercise this arm say so out loud rather than pretending. +// Eight, and the eighth is the profile. Folding the grants and the profile into +// a struct would hide the one thing a reader of a launch has to be able to see +// at the call site: which confinement this program is getting. +#[allow(clippy::too_many_arguments)] fn run_confined( asked: &Asked<'_>, program: &str, @@ -1282,6 +1295,7 @@ fn run_confined( also_readable: &[PathBuf], also_writable: &[PathBuf], environment: &[(String, String)], + profile: &'static str, metrics: &mut Metrics, ) -> Ran { use thalyx_manifest::{Permission, PermissionKind}; @@ -1341,7 +1355,13 @@ fn run_confined( grants, helper: std::env::current_exe().unwrap_or_else(|_| PathBuf::from("thalyx")), request_id: asked.request_id.clone(), - profile: thalyx_sandbox::profile::MODULE_STANDARD, + // Named by the caller, because the two callers are not the same + // thing. A `rust` check runs a compiler tree and asks for the + // profile that confines one; a check that names somebody's program + // gets the profile every unsigned program gets, and widening that + // to suit Cargo would hand every named program a compiler tree's + // filter and six gigabytes for nothing. + profile, environment: environment.to_vec(), }, ); @@ -1566,6 +1586,15 @@ fn rust_check( &readable, std::slice::from_ref(&build_into), &environment, + // **The compiler tree's profile, not the module one.** Cargo runs a + // `rustc` per unit and a build script per dependency that has one, so + // it makes calls an ordinary module does not — see + // `thalyx_sandbox::seccomp::semantic_provider`, which names all four. + // Until 2026-08-30 this ran under the module filter and Cargo was + // killed on `flock`, with `159` as the only thing anybody could read. + // It is also the tree rust-analyzer starts from the other direction, + // under this same profile. + thalyx_sandbox::profile::SEMANTIC_PROVIDER, metrics, ); diff --git a/crates/thalyx-cli/tests/context_is_smaller_than_the_file.rs b/crates/thalyx-cli/tests/context_is_smaller_than_the_file.rs index 0ffa3fa..a8c817d 100644 --- a/crates/thalyx-cli/tests/context_is_smaller_than_the_file.rs +++ b/crates/thalyx-cli/tests/context_is_smaller_than_the_file.rs @@ -21,15 +21,17 @@ fn thalyx() -> &'static str { } /// Rule 3: one variable per requirement, and a skip that says it skipped. +/// +/// The search is `thalyx_rust`'s and not this file's. It used to walk +/// `$HOME/.rustup/toolchains` on its own, which is the exact failure +/// `toolchain.rs` was written to end: `sudo` hands root a `HOME` of `/root`, +/// so under `dev/verify.sh` all five of these tests reported `NOT PROVEN` +/// about a machine whose analyzer the same script had just found and named in +/// `THALYX_RUST_ANALYZER`. A second discovery implementation is a second +/// answer to "does this machine have one", and the wrong one was the one being +/// believed. fn analyzer_or_skip(what: &str) -> bool { - let found = std::env::var_os("HOME") - .map(|home| PathBuf::from(home).join(".rustup").join("toolchains")) - .and_then(|toolchains| std::fs::read_dir(toolchains).ok()) - .into_iter() - .flatten() - .flatten() - .any(|entry| entry.path().join("bin").join("rust-analyzer").is_file()); - if found { + if thalyx_rust::analyzer::find().is_some() { return true; } let message = format!( diff --git a/crates/thalyx-sandbox/src/profile.rs b/crates/thalyx-sandbox/src/profile.rs index c8c93e6..1b6dbd8 100644 --- a/crates/thalyx-sandbox/src/profile.rs +++ b/crates/thalyx-sandbox/src/profile.rs @@ -165,9 +165,17 @@ pub fn resolve(name: &str) -> Result { /// /// Everything else is the module's: its own user, its own root filesystem /// holding only what was granted, its own pid namespace — so killing the one -/// process Thalyx holds kills every compiler underneath it — its own network -/// namespace, which is what "network denied by default" means here, and the -/// same seccomp filter. +/// process Thalyx holds kills every compiler underneath it — and its own +/// network namespace, which is what "network denied by default" means here. +/// +/// - **The filter.** Two numbers changed was the whole difference until +/// 2026-08-30, when a kernel that actually denies was pointed at this profile +/// for the first time and Cargo died on `SIGSYS`. A compiler tree makes two +/// calls an ordinary module does not — see +/// [`seccomp::semantic_provider`](crate::seccomp::semantic_provider), where +/// both are named and neither is `socket`. They are added here and not to +/// `module_standard`, because an ordinary module has no compiler under it and +/// a permission nobody needs is a permission nobody is watching. pub fn semantic_provider() -> Profile { Profile { name: SEMANTIC_PROVIDER, @@ -176,6 +184,7 @@ pub fn semantic_provider() -> Profile { pids_max: Some(2048), cpu_max: None, }, + seccomp: Some(crate::seccomp::semantic_provider()), hostname: "thalyx-semantics", ..module_standard() } diff --git a/crates/thalyx-sandbox/src/seccomp.rs b/crates/thalyx-sandbox/src/seccomp.rs index d5596bc..dee0bd3 100644 --- a/crates/thalyx-sandbox/src/seccomp.rs +++ b/crates/thalyx-sandbox/src/seccomp.rs @@ -161,6 +161,32 @@ impl Guard { permitted, } } + + /// `socketpair`, for the one domain that cannot reach off this machine. + /// + /// A `socketpair` is two connected descriptors and nothing else — there is + /// no address, no route, nobody to accept it. But the syscall takes a + /// domain, and a filter that allowed it outright would be allowing whatever + /// domain a future kernel decides to implement `socketpair` for. `AF_UNIX` + /// is the one this needs and the one that is provably local, so it is the + /// only one on the list, for the same reason the scheduling guard names + /// three policies instead of excluding three. + /// + /// It is **not** in `module_standard`, and the asymmetry with `socket` is + /// the point: `socket` stays denied there and here, so nothing under either + /// filter can construct a descriptor that reaches the network. This one + /// exists because a Rust program that spawns a child process makes it — + /// `std::process` reports the child's `execve` failure back over a + /// `SOCK_SEQPACKET` pair — and a compiler tree is nothing but spawned + /// children. + pub fn local_socket_pairs() -> Self { + // `socketpair(domain, type, protocol, sv)`: the domain is the first. + Guard { + syscall: libc::SYS_socketpair, + argument: 0, + permitted: BTreeSet::from([libc::AF_UNIX as u32]), + } + } } /// The syscalls a confined module may make. @@ -620,6 +646,19 @@ pub fn module_standard() -> Allowlist { libc::SYS_pause, // Synchronisation and time libc::SYS_futex, + // **Issued by the kernel, not by the program.** When a signal + // interrupts a blocking call the kernel rewrites the register to + // `restart_syscall` and re-enters — and seccomp filters that + // re-entry like any other call. So a filter without it kills any + // module that happens to be inside `futex`, `poll` or `wait4` when + // a signal arrives, at a moment nothing in the module chose. That + // is how `cargo` died on 2026-08-30: a `SIGCHLD` from `rustc` + // landed on a thread waiting in `futex`, and the resume was denied. + // + // It permits nothing new. The only call it can resume is one that + // was already entered, and a call is only entered if this filter + // let it through. + libc::SYS_restart_syscall, libc::SYS_nanosleep, libc::SYS_clock_nanosleep, libc::SYS_clock_gettime, @@ -636,6 +675,61 @@ pub fn module_standard() -> Allowlist { ]) } +/// The syscalls a semantic provider needs that an ordinary module does not. +/// +/// `vault/03-Primitivas/Semantica-Compilada.md`, and measured rather than +/// guessed — `dev/foreign-agent-needs.sh` traced a real `cargo check` against +/// `module_standard` and named exactly two calls it makes that the filter does +/// not permit. Both were found by running the thing, which is rule 1: before +/// this, `cargo` under the module filter died on `SIGSYS` and `verify.sh` +/// reported it as `cargo exited 159` — a number that says a program was killed +/// and not which call killed it. +/// +/// - **`flock`.** Cargo takes an advisory lock on `Cargo.lock`, on +/// `$CARGO_HOME/.package-cache` and on the build directory, twenty times in +/// the smallest possible build. It is the *first* of the two it reaches, so +/// it is the one that was killing the process. A lock on a descriptor the +/// process already holds reaches nothing it could not already read or write: +/// the grant is what decided that, and this call cannot widen it. +/// - **`linkat`.** `rustc` puts its output in place by hard-linking it — +/// `target/debug/deps/lib….rmeta` gets a second name rather than a second +/// copy — and this is the call that was killing `rustc` once Cargo could get +/// far enough to start one. A hard link can only be made inside a directory +/// the process can already write, so it reaches nothing the grant did not +/// already hand over. +/// - **`inotify_init1`, `inotify_add_watch`, `inotify_rm_watch`.** +/// rust-analyzer starts a `VfsLoader` thread the moment it answers +/// `initialize`, and that thread's first act is to open an inotify +/// descriptor. It is a *thread* of the server, and the filter's action is +/// `KILL_PROCESS` — so the whole language server died, and what the surface +/// above it reported was `` `initialize`: the server stopped``, a sentence +/// about a protocol rather than about a syscall. An inotify watch can only +/// be placed on a path the process can already open, so the rootfs grant is +/// still what bounds it; `inotify_init` — the legacy call with no flags, +/// which nothing modern makes — is deliberately not here. +/// - **`socketpair`, guarded to `AF_UNIX`.** Rust's `std::process` hands the +/// child's `execve` errno — and its pidfd — back to the parent over a +/// `SOCK_SEQPACKET` pair, on the fork path it takes whenever `posix_spawn` +/// cannot serve the request. Cargo spawns `rustc` that way, and +/// rust-analyzer spawns Cargo. See [`Guard::local_socket_pairs`] for why it +/// is a guard and not a line. +/// +/// Nothing else. In particular `socket`, `connect`, `bind`, `ptrace`, `mount` +/// and `bpf` are as absent here as they are from `module_standard`: a compiler +/// tree is confined *more* than an ordinary module in every dimension that +/// costs — its own root filesystem holding only the granted tree, its own pid +/// namespace, its own empty network namespace — and the only thing it gets more +/// of is two calls and two numbers. +pub fn semantic_provider() -> Allowlist { + module_standard() + .allow(libc::SYS_flock) + .allow(libc::SYS_linkat) + .allow(libc::SYS_inotify_init1) + .allow(libc::SYS_inotify_add_watch) + .allow(libc::SYS_inotify_rm_watch) + .guard(Guard::local_socket_pairs()) +} + /// The syscalls a module needs to *use* an outbound network grant. /// /// Kept apart from [`module_standard`] and added only for a module the human @@ -786,6 +880,123 @@ mod tests { } } + #[test] + fn a_module_may_resume_a_call_a_signal_interrupted() { + // `restart_syscall` is issued by the kernel, not by the program: when a + // signal interrupts a blocking call the register is rewritten and the + // syscall re-entered, and seccomp filters the re-entry. So a filter + // without it kills any module that is inside `futex`, `poll` or + // `nanosleep` when a signal lands — at a moment nothing in the module + // chose, which is the worst possible shape for a denial. + // + // This is asserted against the compiled program rather than by running + // something, and that is a real limit worth naming: producing a + // restarted syscall on purpose needs a signal handler with `SA_RESTART`, + // and `unsafe` lives in `thalyx-syscall`. What proved it is the kernel's + // own record of the kill — `comm="cargo" ... syscall=219` — and stage 59 + // of `dev/verify.sh` is where it stays proven by a real Cargo. + let program = module_standard().compile().unwrap(); + assert_eq!( + evaluate(&program, AUDIT_ARCH_X86_64, libc::SYS_restart_syscall), + SECCOMP_RET_ALLOW + ); + } + + #[test] + fn a_compiler_tree_may_watch_the_tree_it_was_given_and_a_module_may_not() { + // rust-analyzer's `VfsLoader` thread opens an inotify descriptor as + // soon as the server answers `initialize`, and the filter's action is + // `KILL_PROCESS` — so the whole language server went down and the + // surface above it reported that the server had stopped. `comm="VfsLoader" + // ... syscall=294` in the kernel's audit log is what named it. + // + // Both columns, for the same reason as everywhere else here: without + // the second, a filter that permitted everything would pass this. + let provider = semantic_provider().compile().unwrap(); + let module = module_standard().compile().unwrap(); + for syscall in [ + libc::SYS_inotify_init1, + libc::SYS_inotify_add_watch, + libc::SYS_inotify_rm_watch, + ] { + assert_eq!( + evaluate(&provider, AUDIT_ARCH_X86_64, syscall), + SECCOMP_RET_ALLOW, + "a semantic provider cannot watch syscall {syscall}" + ); + assert_eq!( + evaluate(&module, AUDIT_ARCH_X86_64, syscall), + SECCOMP_RET_KILL_PROCESS, + "an ordinary module was given syscall {syscall}, which nothing asked for" + ); + } + } + + #[test] + fn a_socket_pair_is_permitted_for_one_domain_and_no_other() { + // The guard, read the way the kernel reads it — including the top half + // of the register, which is the half a caller chooses and a guard that + // only ever saw 32-bit values would never look at. + let program = semantic_provider().compile().unwrap(); + let call = |domain: u64| { + evaluate_call( + &program, + AUDIT_ARCH_X86_64, + libc::SYS_socketpair, + [domain, 0, 0, 0, 0, 0], + ) + }; + assert_eq!(call(libc::AF_UNIX as u64), SECCOMP_RET_ALLOW); + for refused in [ + libc::AF_INET as u64, + libc::AF_INET6 as u64, + libc::AF_NETLINK as u64, + // The same value in the low half, with a top half the guard would + // miss if it read only the word it was shown. + (1u64 << 32) | libc::AF_UNIX as u64, + ] { + assert_eq!( + call(refused), + SECCOMP_RET_KILL_PROCESS, + "socketpair was permitted for domain {refused}" + ); + } + // And the plain syscall stays absent on both, which is the decree the + // guard exists to keep: no module and no provider constructs a socket. + for allowlist in [semantic_provider(), module_standard()] { + assert_eq!( + evaluate( + &allowlist.compile().unwrap(), + AUDIT_ARCH_X86_64, + libc::SYS_socket + ), + SECCOMP_RET_KILL_PROCESS + ); + } + } + + #[test] + fn the_provider_adds_to_the_module_filter_and_takes_nothing_away() { + // The relationship the whole arrangement rests on. If the provider's + // list were ever written out by hand instead of built from the + // module's, a syscall dropped from it would look like a tighter filter + // and be a different one. + let module = module_standard(); + let provider = semantic_provider(); + for syscall in module.syscalls() { + assert!( + provider.contains(syscall), + "syscall {syscall} is in module_standard and not in semantic_provider" + ); + } + // Five plain allowances, and the socket pair is not among them: a + // guarded syscall is not in `len()` and reads as absent from + // `contains`, which is the cautious way round and the reason it gets + // its own assertion rather than a number. + assert_eq!(provider.len(), module.len() + 5); + assert_eq!(provider.guards().count(), module.guards().count() + 1); + } + #[test] fn the_syscalls_that_reshape_the_sandbox_are_denied() { // Named individually rather than checked as "not in the set". The diff --git a/crates/thalyx-sandbox/tests/the_compiler_tree_under_the_filter.rs b/crates/thalyx-sandbox/tests/the_compiler_tree_under_the_filter.rs new file mode 100644 index 0000000..d4ad996 --- /dev/null +++ b/crates/thalyx-sandbox/tests/the_compiler_tree_under_the_filter.rs @@ -0,0 +1,209 @@ +//! What a compiler tree may do that an ordinary module may not — asked of real +//! operations, under the two real filters, side by side. +//! +//! ## The failure this file exists to stop +//! +//! On 2026-08-30 `dev/verify.sh` ran Cargo and rust-analyzer under a kernel +//! that was finally *denying* rather than watching, and both died. What the +//! two stages could report was `cargo exited 159` and `` `initialize`: the +//! server stopped``. Neither sentence names a syscall, because a +//! `SECCOMP_RET_KILL_PROCESS` does not tell the program anything — it tells the +//! *kernel's audit log*, which is where the four numbers below were finally +//! read from: +//! +//! ```text +//! comm="cargo" syscall=73 → flock +//! comm="rustc" syscall=265 → linkat +//! comm="cargo" syscall=219 → restart_syscall +//! comm="VfsLoader" syscall=294 → inotify_init1 +//! ``` +//! +//! Rule 1 of `Estrategia-de-Pruebas.md`: every one of them came from running +//! the thing. Not one was visible in the source of anything Thalyx wrote. +//! +//! ## Why every claim here is two columns +//! +//! Rule 4. `semantic_provider` permitting `flock` proves nothing on its own — +//! a filter that permitted everything would pass that assertion, and so would +//! a machine where seccomp does not install. So each operation is run under +//! **both** filters and the pair is asserted: permitted under the provider's, +//! killed under the module's. The second column is what makes the first mean +//! "this was added deliberately" rather than "nothing is being enforced". +//! +//! [`a_socket_pair_is_not_a_socket`] is the same shape pointed the other way, +//! and it is the one that guards the decree: `socketpair` was added, and a +//! network socket has to stay dead under the provider's filter too, or the +//! addition quietly became the thing `Sandbox-Ejecucion.md` refuses. +//! +//! ## Why it is a re-executed process and not a call +//! +//! A seccomp filter is irrevocable and inherited. Installing one inside the +//! test harness would confine every test that runs after it in the same +//! process — and `cargo test` runs a binary's tests as threads of one process. + +use std::os::unix::process::ExitStatusExt; +use std::process::Command; + +/// Set on the re-executed child, naming which operation it is to attempt. +const ARM: &str = "THALYX_FILTER_ARM"; +/// Which of the two filters the arm installs. +const FILTER: &str = "THALYX_FILTER_WHICH"; +/// A directory the arm may write in, made by the parent. +const WORK: &str = "THALYX_FILTER_WORK"; + +/// The filter would not install: a fact about the kernel, not about the list. +const NO_SECCOMP: i32 = 90; +/// The operation was refused by something that is not the filter. +const REFUSED: i32 = 93; + +/// `SIGSYS` as the parent sees it. The filter's only action is to kill, so +/// nothing else on this path produces this number. +const KILLED_BY_THE_FILTER: i32 = 128 + libc::SIGSYS; + +/// One operation, under one filter, in a process of its own. +/// +/// Not a claim — it does nothing at all unless a test below re-executed this +/// binary. `std::process::exit` carries the answer out, because a panic here +/// would arrive as libtest's exit code and say nothing about the operation. +#[test] +fn the_arm_that_runs_under_a_filter() { + let Ok(operation) = std::env::var(ARM) else { + return; + }; + let work = std::path::PathBuf::from(std::env::var(WORK).expect("a work directory")); + + let allowlist = match std::env::var(FILTER).as_deref() { + Ok("module") => thalyx_sandbox::seccomp::module_standard(), + Ok("provider") => thalyx_sandbox::seccomp::semantic_provider(), + other => panic!("unknown filter `{other:?}`"), + }; + if allowlist.install().is_err() { + std::process::exit(NO_SECCOMP); + } + + // Everything below is written in safe `std`, which is not a stylistic + // choice: `unsafe` lives in `thalyx-syscall` and nowhere else. It is also + // the more honest instrument — `File::try_lock` reaching `flock` and + // `UnixStream::pair` reaching `socketpair` is exactly how the programs that + // died reached them, rather than a hand-made call nothing really makes. + let done = match operation.as_str() { + // Cargo locks `Cargo.lock`, `$CARGO_HOME/.package-cache` and the build + // directory. Twenty times in the smallest build there is. + "flock" => { + std::fs::File::create(work.join("locked")).is_ok_and(|file| file.try_lock().is_ok()) + } + // `rustc` puts its output in place by hard-linking it. + "hardlink" => std::fs::write(work.join("from"), b"output") + .and_then(|()| std::fs::hard_link(work.join("from"), work.join("to"))) + .is_ok(), + // `std::process` hands the child's `execve` errno back over one of + // these, on the fork path Cargo's spawns take. + "socketpair" => std::os::unix::net::UnixStream::pair().is_ok(), + // The control. Not a capability being claimed — a door that has to + // stay shut on both sides of every other column here. + "network-socket" => std::net::UdpSocket::bind("127.0.0.1:0").is_ok(), + other => panic!("unknown operation `{other}`"), + }; + std::process::exit(if done { 0 } else { REFUSED }); +} + +/// What one operation did under one filter, in a process of its own. +/// +/// `None` when the filter would not install on this kernel — which is a skip +/// and not a pass, and is why every caller goes through [`not_proven`]. +fn under(filter: &str, operation: &str) -> Option { + let held = tempfile::tempdir().expect("a work directory"); + let binary = std::env::current_exe().expect("this test binary"); + let output = Command::new(&binary) + .args(["the_arm_that_runs_under_a_filter", "--exact", "--nocapture"]) + .env(ARM, operation) + .env(FILTER, filter) + .env(WORK, held.path()) + .output() + .expect("re-executing this test binary"); + + match (output.status.code(), output.status.signal()) { + (Some(NO_SECCOMP), _) => None, + // The arm reports what happened to the operation in its own exit code, + // so an arm killed on a call libtest makes *after* installing the + // filter must never be read as the operation being killed. That would + // be the harness answering a question about Thalyx — rule 5. + (Some(code), _) => Some(code), + (None, Some(signal)) => Some(128 + signal), + (None, None) => unreachable!("a process that neither exited nor was signalled"), + } +} + +/// Rule 3: a skip that says it skipped, and one variable that demands the check. +fn not_proven(why: &str) { + if std::env::var_os("THALYX_REQUIRE_SECCOMP_TESTS").is_some() { + panic!("THALYX_REQUIRE_SECCOMP_TESTS is set and {why}"); + } + eprintln!( + "NOT PROVEN: {why}, so nothing here ran a real operation under a real \ + filter. Set THALYX_REQUIRE_SECCOMP_TESTS=1 to make this a failure." + ); +} + +/// One capability: permitted for a compiler tree, killed for an ordinary module. +fn only_the_compiler_tree_may(operation: &str, what: &str) { + let Some(provider) = under("provider", operation) else { + not_proven("this kernel would not install a seccomp filter"); + return; + }; + let module = under("module", operation).expect("the filter installed a moment ago"); + + assert_eq!( + provider, 0, + "a semantic provider could not {what}: the arm exited {provider} \ + (128+31 is SIGSYS, which is the filter killing it). This is the call \ + that stopped the compiler tree, and permitting it is the whole point \ + of the provider having a filter of its own" + ); + assert_eq!( + module, KILLED_BY_THE_FILTER, + "an ordinary module was allowed to {what} (the arm exited {module}). \ + Then the provider's filter is not a superset of the module's — it is \ + the module's, and this file proves nothing about what was added" + ); +} + +#[test] +fn only_a_compiler_tree_may_lock_a_file_it_already_holds() { + only_the_compiler_tree_may("flock", "lock a file it opened"); +} + +#[test] +fn only_a_compiler_tree_may_hard_link_inside_what_it_was_given() { + only_the_compiler_tree_may("hardlink", "hard-link a file it wrote"); +} + +#[test] +fn only_a_compiler_tree_may_make_a_pair_of_connected_descriptors() { + only_the_compiler_tree_may("socketpair", "make a socket pair"); +} + +#[test] +fn a_socket_pair_is_not_a_socket() { + // The decree `socketpair` was added against, checked rather than promised. + // A guard on the domain is only worth anything if the domains it leaves out + // are still dead, and the cheapest way for this addition to have gone wrong + // would have been to write `.allow(SYS_socket)` next to it and never notice. + let Some(provider) = under("provider", "network-socket") else { + not_proven("this kernel would not install a seccomp filter"); + return; + }; + let module = under("module", "network-socket").expect("the filter installed a moment ago"); + + assert_eq!( + provider, KILLED_BY_THE_FILTER, + "a semantic provider constructed a network socket (the arm exited \ + {provider}). `socketpair` was permitted for a pair of descriptors that \ + cannot leave the machine; if `socket` came with it, the provider has \ + the network `Sandbox-Ejecucion.md` denies it" + ); + assert_eq!( + module, KILLED_BY_THE_FILTER, + "and an ordinary module still may not either" + ); +}