Skip to content

fix(runtime): make subprocess plugin sandbox secure-by-default (#2)#5862

Merged
houko merged 2 commits into
mainfrom
fix/plugin-sandbox-secure-defaults
May 29, 2026
Merged

fix(runtime): make subprocess plugin sandbox secure-by-default (#2)#5862
houko merged 2 commits into
mainfrom
fix/plugin-sandbox-secure-defaults

Conversation

@houko

@houko houko commented May 29, 2026

Copy link
Copy Markdown
Contributor

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_filesystem now default to false (were true) in every default source, kept consistent:
    • HookConfig::default() in crates/librefang-runtime/src/plugin_runtime.rs.
    • The serde default for ContextEngineHooks in crates/librefang-types/src/config/types.rs — the two fields moved from #[serde(default = "default_true_bool")] to plain #[serde(default)] (bool false). The now-unused default_true_bool helper is removed. This also makes the derived Default and the serde-omitted default agree; before this change they silently disagreed (derived Default already produced false, serde produced true).
  • seccomp-sandbox and landlock-sandbox are added to the runtime's default feature set.

Cross-platform gating (confirmed)

The Linux-only deps seccompiler and landlock were under plain [dependencies]. Adding the features to default as-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:

  • The dep:seccompiler / dep:landlock feature activations are no-ops on non-Linux targets (the dep simply isn't present for that target).
  • All code that references these crates is already #[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 allowlist was incomplete. With seccomp-sandbox off by default the allowlist was never exercised by the standard test path. Enabling it by default revealed that the ~60-syscall KillProcess allowlist 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) was SIGSYS-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).
  • unshare namespace probe was wrong. try_wrap_with_unshare / try_wrap_with_unshare_mount probed unshare --help to decide whether to wrap a deny-network / deny-filesystem hook. --help succeeding does not mean the kernel will grant the namespace: in unprivileged containers (Docker without --privileged) and hardened CI, CLONE_NEWNET / CLONE_NEWNS returns EPERM. The wrapper was therefore prepended, unshare exited 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 runs unshare --<ns> -- true and 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:

[hooks]
allow_network = true       # only if the plugin needs outbound network
allow_filesystem = true    # only if the plugin needs to write files

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; derived Default matches 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_trip runs 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 / landlock under [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.

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).
@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution size/L 250-999 lines changed area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox labels May 29, 2026
…cure-defaults

# Conflicts:
#	.secrets.baseline
@houko
houko merged commit 2f7f146 into main May 29, 2026
17 checks passed
@houko
houko deleted the fix/plugin-sandbox-secure-defaults branch May 29, 2026 16:43
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels May 29, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant