Skip to content

Commit f2b8935

Browse files
committed
feat(sandbox): bind /proc in bwrap on Lakebox hosts
The linux_bwrap sandbox mounts a fresh procfs under --unshare-pid, but a Lakebox microVM masks /proc so that mount returns EPERM and the sandbox fails to start. That blocked linux_bwrap — and the L7 egress management built on top of it — on the Lakebox backend. Bind the existing /proc instead of mounting a fresh one, but only on outer sandbox backends known to be safe for it (allow-list: lakebox). The backend is read from OMNIGENT_HOST_SANDBOX_BACKEND when set, else autodetected via the /run/lakebox marker. Everywhere else the fresh-proc mount and its fail-closed behavior stay unchanged. Binding /proc exposes the outer process list and world-readable per-proc files (cmdline/comm/stat/status). The retained user namespace still blocks ptrace-gated files (environ/mem/maps/fd) and --unshare-pid still contains signalling, so the leak is acceptable on a single-tenant Lakebox microVM. Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
1 parent 3b9d8d5 commit f2b8935

2 files changed

Lines changed: 214 additions & 3 deletions

File tree

omnigent/inner/bwrap_sandbox.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
``/etc/ld.so.cache``, ``/etc/ld.so.conf``,
1919
``/etc/ld.so.conf.d``, ``/etc/ssl``, ``/etc/ca-certificates``,
2020
``/etc/pki``) bound read-only via ``--ro-bind-try``.
21-
- Fresh ``/proc``, ``/dev``, and ``/tmp``.
21+
- Fresh ``/proc`` (bind-mounted from the host on outer sandboxes that
22+
forbid a fresh procfs mount, e.g. Lakebox — see
23+
:func:`_should_bind_host_proc`), ``/dev``, and ``/tmp``.
2224
- Cwd bind-mounted read-only by default; explicit
2325
``write_paths: ["."]`` flips it to read-write. Top-level
2426
dotfiles / dotdirs in cwd are tmpfs-masked unless their name is
@@ -175,6 +177,83 @@
175177
)
176178

177179

180+
# ---------------------------------------------------------------------------
181+
# Nested-sandbox /proc handling
182+
# ---------------------------------------------------------------------------
183+
184+
# Outer sandbox backends whose microVM/container permits binding the
185+
# existing ``/proc`` but forbids mounting a FRESH procfs under
186+
# ``--unshare-pid`` (masked ``/proc`` overmounts make ``mount proc``
187+
# return EPERM). On these backends the bwrap wrap binds the host
188+
# ``/proc`` instead of emitting ``--proc /proc``.
189+
#
190+
# Why an explicit allow-list rather than a "fresh-proc mount failed, retry
191+
# with a bind" fallback: binding the existing ``/proc`` is a security
192+
# DOWNGRADE. It exposes the outer process list plus the world-readable
193+
# per-process files (``cmdline`` / ``comm`` / ``stat`` / ``status``) to the
194+
# sandboxed helper. It does NOT expose the ptrace-gated files (``environ``
195+
# / ``mem`` / ``maps`` / ``fd``): the retained user namespace keeps those
196+
# blocked even for same-uid targets and even as namespaced root, and
197+
# ``--unshare-pid`` still contains signalling. That leak is acceptable on a
198+
# single-tenant dev microVM (Lakebox) but not on an arbitrary host, so the
199+
# downgrade is only taken for vetted backends. Add entries here as more
200+
# backends are verified safe.
201+
_PROC_BIND_HOST_BACKENDS: frozenset[str] = frozenset({"lakebox"})
202+
203+
# Env var an outer launcher may set to name the host's outer sandbox
204+
# backend (e.g. ``lakebox``). Authoritative when present; otherwise the
205+
# backend is autodetected (see ``_detect_host_sandbox_backend``). Note it
206+
# must survive ``SandboxPolicy.spawn_env_allowlist`` pruning to be visible
207+
# on the re-exec launcher path — the marker autodetect below is the
208+
# prune-proof fallback that lakebox relies on today.
209+
_HOST_SANDBOX_BACKEND_ENV = "OMNIGENT_HOST_SANDBOX_BACKEND"
210+
211+
# Marker directory the Databricks Lakebox runtime seeds inside every
212+
# microVM. Used to autodetect lakebox when the launcher hasn't declared the
213+
# backend via ``_HOST_SANDBOX_BACKEND_ENV``.
214+
_LAKEBOX_MARKER = Path("/run/lakebox")
215+
216+
217+
def _detect_host_sandbox_backend() -> str | None:
218+
"""
219+
Identify the outer sandbox backend this host process runs under.
220+
221+
Prefers the explicit ``OMNIGENT_HOST_SANDBOX_BACKEND`` env var (set by
222+
the launcher that provisioned this host); otherwise falls back to
223+
lakebox autodetection via the :data:`_LAKEBOX_MARKER` directory. Runs
224+
in the host/parent process — never inside the agent's sandbox — so the
225+
signals it reads cannot be forged from within the sandbox.
226+
227+
:returns: Lower-cased backend name (e.g. ``"lakebox"``), or ``None``
228+
when the host is not running under a recognised outer sandbox.
229+
"""
230+
declared = os.environ.get(_HOST_SANDBOX_BACKEND_ENV)
231+
if declared and declared.strip():
232+
return declared.strip().lower()
233+
try:
234+
if _LAKEBOX_MARKER.is_dir():
235+
return "lakebox"
236+
except OSError:
237+
pass
238+
return None
239+
240+
241+
def _should_bind_host_proc() -> bool:
242+
"""
243+
Whether the bwrap wrap should bind the existing ``/proc`` instead of
244+
mounting a fresh procfs.
245+
246+
``True`` only when the host runs under an outer sandbox backend in
247+
:data:`_PROC_BIND_HOST_BACKENDS` (lakebox today). On every other host
248+
the fresh-proc mount is kept, so ordinary hosts — and their fail-closed
249+
behaviour when a fresh procfs mount is denied — are unchanged.
250+
251+
:returns: ``True`` to emit ``--bind /proc /proc``; ``False`` to emit
252+
``--proc /proc``.
253+
"""
254+
return _detect_host_sandbox_backend() in _PROC_BIND_HOST_BACKENDS
255+
256+
178257
# ---------------------------------------------------------------------------
179258
# Backend
180259
# ---------------------------------------------------------------------------
@@ -344,9 +423,18 @@ def wrap_launcher_argv(
344423
# /proc, /dev (filtered by bwrap to a safe minimal device set),
345424
# and a private /tmp so the agent's writes there don't pollute
346425
# the host.
426+
#
427+
# ``--proc`` mounts a FRESH procfs tied to the new PID namespace so
428+
# the helper sees only its own processes. Some nested outer
429+
# sandboxes (Lakebox) forbid that mount under ``--unshare-pid``
430+
# (masked ``/proc`` overmounts -> ``mount proc: EPERM``), so there
431+
# we bind the host ``/proc`` instead. See ``_should_bind_host_proc``
432+
# for the gate and the security trade-off.
433+
if _should_bind_host_proc():
434+
bwrap_args += ["--bind", "/proc", "/proc"]
435+
else:
436+
bwrap_args += ["--proc", "/proc"]
347437
bwrap_args += [
348-
"--proc",
349-
"/proc",
350438
"--dev",
351439
"/dev",
352440
"--tmpfs",

tests/inner/test_bwrap_sandbox.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@
3636

3737
import pytest
3838

39+
from omnigent.inner import bwrap_sandbox
3940
from omnigent.inner.bwrap_sandbox import (
4041
_ALLOWED_SOCKET_FAMILIES,
4142
_CLONE_NEW_FLAG_BITS,
4243
_DEFAULT_CWD_ALLOW_HIDDEN,
44+
_HOST_SANDBOX_BACKEND_ENV,
45+
_PROC_BIND_HOST_BACKENDS,
4346
BwrapSandboxBackend,
4447
_bwrap_extra_seccomp_rules,
48+
_detect_host_sandbox_backend,
49+
_should_bind_host_proc,
4550
)
4651
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
4752
from omnigent.inner.sandbox import SandboxPolicy, with_denied_unix_sockets
@@ -879,6 +884,124 @@ def _last_index(pred: object) -> int:
879884
)
880885

881886

887+
# ---------------------------------------------------------------------------
888+
# Nested-sandbox /proc handling (lakebox proc bind)
889+
# ---------------------------------------------------------------------------
890+
891+
892+
def _clear_host_backend_signals(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
893+
"""
894+
Neutralise every host-backend signal so detection returns ``None``.
895+
896+
Removes the declaration env var and repoints the lakebox marker at a
897+
path that does not exist, isolating the test from the machine it runs
898+
on — which must not be assumed to be (or not to be) a lakebox microVM.
899+
"""
900+
monkeypatch.delenv(_HOST_SANDBOX_BACKEND_ENV, raising=False)
901+
monkeypatch.setattr(bwrap_sandbox, "_LAKEBOX_MARKER", tmp_path / "no-such-marker")
902+
903+
904+
def test_detect_host_backend_none_without_signals(
905+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
906+
) -> None:
907+
"""
908+
With no env declaration and no marker, no outer backend is detected,
909+
so the proc bind stays off (fresh procfs is kept everywhere by
910+
default).
911+
"""
912+
_clear_host_backend_signals(monkeypatch, tmp_path)
913+
assert _detect_host_sandbox_backend() is None
914+
assert _should_bind_host_proc() is False
915+
916+
917+
def test_detect_host_backend_env_declaration_is_normalised(
918+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
919+
) -> None:
920+
"""
921+
``OMNIGENT_HOST_SANDBOX_BACKEND`` is the explicit, authoritative
922+
signal; it is trimmed and lower-cased so callers don't have to match
923+
an exact casing, and ``lakebox`` is on the proc-bind allow-list.
924+
"""
925+
_clear_host_backend_signals(monkeypatch, tmp_path)
926+
monkeypatch.setenv(_HOST_SANDBOX_BACKEND_ENV, " LakeBox ")
927+
assert _detect_host_sandbox_backend() == "lakebox"
928+
assert _should_bind_host_proc() is True
929+
930+
931+
def test_detect_host_backend_env_non_lakebox_keeps_fresh_proc(
932+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
933+
) -> None:
934+
"""
935+
A declared backend that is NOT on :data:`_PROC_BIND_HOST_BACKENDS`
936+
must not trigger the proc downgrade — even if the lakebox marker
937+
happens to exist — because the explicit declaration is authoritative.
938+
"""
939+
marker = tmp_path / "run-lakebox"
940+
marker.mkdir()
941+
monkeypatch.setattr(bwrap_sandbox, "_LAKEBOX_MARKER", marker)
942+
monkeypatch.setenv(_HOST_SANDBOX_BACKEND_ENV, "modal")
943+
assert _detect_host_sandbox_backend() == "modal"
944+
assert "modal" not in _PROC_BIND_HOST_BACKENDS
945+
assert _should_bind_host_proc() is False
946+
947+
948+
def test_detect_host_backend_marker_autodetects_lakebox(
949+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
950+
) -> None:
951+
"""
952+
When no env var is set, the ``/run/lakebox`` marker directory
953+
autodetects lakebox. This is the prune-proof fallback the re-exec
954+
launcher path relies on (the env var may be stripped by the spawn
955+
allow-list, but the filesystem marker survives).
956+
"""
957+
marker = tmp_path / "run-lakebox"
958+
marker.mkdir()
959+
monkeypatch.delenv(_HOST_SANDBOX_BACKEND_ENV, raising=False)
960+
monkeypatch.setattr(bwrap_sandbox, "_LAKEBOX_MARKER", marker)
961+
assert _detect_host_sandbox_backend() == "lakebox"
962+
assert _should_bind_host_proc() is True
963+
964+
965+
def test_wrap_launcher_argv_fresh_proc_by_default(
966+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
967+
) -> None:
968+
"""
969+
On an ordinary host the wrap emits ``--proc /proc`` (a fresh procfs
970+
tied to the new PID namespace) and never binds the host ``/proc``.
971+
"""
972+
_clear_host_backend_signals(monkeypatch, tmp_path)
973+
backend = _make_backend()
974+
policy = _make_policy(tmp_path)
975+
argv = backend.wrap_launcher_argv([sys.executable, "-c", "pass"], policy, tmp_path)
976+
assert _has_pair_single_dest(argv, "--proc", "/proc")
977+
assert not _has_pair(argv, "--bind", "/proc", "/proc")
978+
979+
980+
def test_wrap_launcher_argv_binds_proc_on_lakebox(
981+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
982+
) -> None:
983+
"""
984+
On a lakebox host the wrap binds the existing ``/proc`` instead of
985+
mounting a fresh procfs (lakebox's masked ``/proc`` overmounts make
986+
the fresh mount fail under ``--unshare-pid``). ``/dev`` and ``/tmp``
987+
are unaffected.
988+
"""
989+
_clear_host_backend_signals(monkeypatch, tmp_path)
990+
monkeypatch.setenv(_HOST_SANDBOX_BACKEND_ENV, "lakebox")
991+
backend = _make_backend()
992+
policy = _make_policy(tmp_path)
993+
argv = backend.wrap_launcher_argv([sys.executable, "-c", "pass"], policy, tmp_path)
994+
assert _has_pair(argv, "--bind", "/proc", "/proc"), (
995+
"lakebox host must bind the existing /proc; got no `--bind /proc /proc`."
996+
)
997+
assert not _has_pair_single_dest(argv, "--proc", "/proc"), (
998+
"lakebox host must NOT also mount a fresh procfs — the two would "
999+
"conflict at the same mountpoint."
1000+
)
1001+
assert _has_pair_single_dest(argv, "--dev", "/dev")
1002+
assert "--tmpfs" in argv
1003+
1004+
8821005
# ---------------------------------------------------------------------------
8831006
# Dotfile masking + symlink defense
8841007
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)