fix(runtime): make subprocess plugin sandbox secure-by-default (#2)#5862
Merged
Conversation
Plugin hook subprocesses previously ran with network and filesystem access wide open, and the Linux seccomp / Landlock backends were not in the default feature set — so the shipped binary applied no syscall or LSM filtering at all. Secure-by-default changes (breaking): - Flip `allow_network` / `allow_filesystem` defaults from `true` to `false` in every default source: `HookConfig::default()` in plugin_runtime.rs and the serde `#[serde(default)]` for `ContextEngineHooks` in librefang-types (was `default_true_bool`, now the bool default `false`, which also makes the derived `Default` and the serde-omitted default agree — they previously disagreed). - Enable `seccomp-sandbox` and `landlock-sandbox` in the runtime's default feature set. Their Linux-only deps (`seccompiler`, `landlock`) are moved under `[target.'cfg(target_os = "linux")'.dependencies]` so the features can ship in `default` without breaking macOS / Windows builds (the `dep:` activations are no-ops where the dep is absent, and all code touching them is already `cfg(all(target_os = "linux", feature))`). Two latent bugs surfaced by turning the sandbox on by default (in-scope, same file / feature): - The seccomp allowlist was missing glibc / language-runtime start-up syscalls (rseq, set_robust_list, rt_sigtimedwait, statx, sched_getaffinity, …). With seccomp off by default the gap was never exercised; on by default it SIGSYS-killed every hook child before it read stdin (observed as "Broken pipe"). Added the universal start-up set (present on both x86_64 and aarch64). - The `unshare` namespace probe checked only `unshare --help`, not whether the kernel grants the namespace. In unprivileged containers / hardened CI the binary exists but CLONE_NEWNET / CLONE_NEWNS returns EPERM, so wrapping a deny-network / deny-filesystem hook with `unshare` killed the child instead of failing open to env-var / Landlock isolation. The probe now runs `unshare --<ns> -- true` and only wraps when the namespace can actually be created. Tests: - librefang-types: omitted `[hooks]` flags deserialize to deny; explicit opt-in honoured; derived Default matches serde default. - librefang-runtime: `HookConfig::default()` denies both; `locked_down_default_hook_completes_round_trip` runs a hook under the full deny-by-default sandbox (seccomp + unshare-or-fallback) and asserts a clean JSON round trip — the regression guard for the seccomp allowlist and the unshare probe. Verified in the Linux dev container: `cargo check -p librefang-runtime --lib`, `cargo clippy -p librefang-runtime --lib -D warnings`, `cargo test -p librefang-runtime --lib plugin` (58 pass), `cargo test -p librefang-types context_engine_hooks` (2 pass).
…cure-defaults # Conflicts: # .secrets.baseline
houko
added a commit
that referenced
this pull request
May 30, 2026
…ries (fixes native_runtime_timeout CI failure) (#5920) * fix(runtime): allowlist glibc-startup syscalls for exec'd plugin binaries #5862 enabled seccomp by default but the allowlist omits faccessat2, statx, rseq, set_robust_list, sched_getaffinity and sched_yield. A native hook that execs a second binary (e.g. a hook running `sleep`, which execs /usr/bin/sleep) is SIGSYS-killed at the dynamic-linker/glibc startup of that second exec on modern Ubuntu glibc, returning a non-Timeout error. This deterministically fails native_runtime_timeout_is_enforced on Linux CI (~0.2s, well under the 1s timeout) while passing on macOS (no seccomp). These are benign program-startup syscalls; allowing them does not weaken the sandbox's intent. * test(runtime): simplify timeout-assert diagnostic to one line * fix(runtime): allowlist job-control syscalls (setpgid etc.) for shelled-out hooks * fix(runtime): allowlist x86_64 vfork so dash can spawn external commands under seccomp --------- Co-authored-by: Evan <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the "subprocess plugin sandbox runs wide open" security issue (#2) by making the sandbox secure-by-default.
Plugin hook subprocesses previously ran with network and filesystem access enabled by default, and the Linux seccomp / Landlock backends were not in the runtime's default feature set — so the shipped binary applied no syscall or LSM filtering at all unless an operator hand-enabled the features and every plugin opted into restriction. This flips the posture: deny by default, opt in explicitly.
Changes
Breaking: secure-by-default
allow_network/allow_filesystemnow default tofalse(weretrue) in every default source, kept consistent:HookConfig::default()incrates/librefang-runtime/src/plugin_runtime.rs.ContextEngineHooksincrates/librefang-types/src/config/types.rs— the two fields moved from#[serde(default = "default_true_bool")]to plain#[serde(default)](boolfalse). The now-unuseddefault_true_boolhelper is removed. This also makes the derivedDefaultand the serde-omitted default agree; before this change they silently disagreed (derivedDefaultalready producedfalse, serde producedtrue).seccomp-sandboxandlandlock-sandboxare added to the runtime'sdefaultfeature set.Cross-platform gating (confirmed)
The Linux-only deps
seccompilerandlandlockwere under plain[dependencies]. Adding the features todefaultas-is would have pulled them into the macOS / Windows dependency graph and broken those CI lanes. They are now moved under[target.'cfg(target_os = "linux")'.dependencies], so:dep:seccompiler/dep:landlockfeature activations are no-ops on non-Linux targets (the dep simply isn't present for that target).#[cfg(all(target_os = "linux", feature = ...))], so the source compiles unchanged on every platform.cargo check -p librefang-runtime --lib(which now builds with the new default features) compiles cleanly in the Linux dev container. Verified on Linux only (the dev container is Linux). The macOS / Windows builds need CI confirmation — the change is the dependency-section move above, which is the standard pattern for platform-gated optional deps, but it has not been compiled on those hosts here.Two latent bugs surfaced by turning the sandbox on by default
Both are in the same file / feature as the change, so they are in-scope rather than deferred:
seccomp-sandboxoff by default the allowlist was never exercised by the standard test path. Enabling it by default revealed that the ~60-syscallKillProcessallowlist was missing glibc / language-runtime start-up syscalls (rseq,set_robust_list,get_robust_list,rt_sigtimedwait,restart_syscall,clock_getres,sched_getaffinity,sched_yield,statx,membarrier). Every hook child (/bin/sh,python3) wasSIGSYS-killed before it read stdin, surfacing to the parent as "Broken pipe". Added the universal start-up set (present on both x86_64 and aarch64).unsharenamespace probe was wrong.try_wrap_with_unshare/try_wrap_with_unshare_mountprobedunshare --helpto decide whether to wrap a deny-network / deny-filesystem hook.--helpsucceeding does not mean the kernel will grant the namespace: in unprivileged containers (Docker without--privileged) and hardened CI,CLONE_NEWNET/CLONE_NEWNSreturns EPERM. The wrapper was therefore prepended,unshareexited non-zero before exec, and the child was killed — which, now that deny is the default, would break essentially every plugin in such environments. The probe now runsunshare --<ns> -- trueand only wraps when the namespace can actually be created; otherwise it falls open to the env-var / Landlock soft isolation that is applied separately (never fatal to the hook).Migration (operator action required)
Existing plugins that relied on the old open-by-default behaviour will stop reaching the network / writing files after upgrade until they declare the access in their
plugin.toml:A plugin that needs neither requires no change. Hooks that only read stdin and write stdout (the common case) keep working with no edit.
Tests
librefang-types: omitted[hooks]flags deserialize to deny; explicit opt-in is honoured; derivedDefaultmatches the serde-omitted default. (context_engine_hooks_omitted_sandbox_flags_default_to_deny,context_engine_hooks_explicit_opt_in_is_honoured)librefang-runtime:HookConfig::default()denies both (hook_config_default_denies_network_and_filesystem);locked_down_default_hook_completes_round_tripruns a hook under the full deny-by-default sandbox (seccomp filter applied + unshare-or-fallback) and asserts a clean JSON round trip — the direct regression guard for both the seccomp allowlist completeness and the unshare probe.Verification (Linux dev container)
cargo check -p librefang-runtime --lib— clean (compiles with the new default features incl. seccomp + landlock).cargo clippy -p librefang-runtime --lib -- -D warnings— zero warnings.cargo test -p librefang-runtime --lib plugin— 58 passed, 0 failed.cargo test -p librefang-types context_engine_hooks— 2 passed, 0 failed.cargo fmt -p librefang-runtime -p librefang-types --check— clean.Cross-platform caveat for reviewers
Docker-based verification covers Linux only. Please confirm the macOS and Windows CI lanes are green — the only platform-sensitive change is moving
seccompiler/landlockunder[target.'cfg(target_os = "linux")'.dependencies]; the runtime code paths that touch them are already Linux-gated, so non-Linux builds should treat the new default features as no-ops, but that has not been compiled outside the Linux container here.