Skip to content

Add an opt-in bubblewrap sandbox for the whole run - #1925

Draft
marcusr-amd wants to merge 2 commits into
feat/physical-aifrom
marcusr/sandbox-reexec
Draft

marcusr-amd wants to merge 2 commits into
feat/physical-aifrom
marcusr/sandbox-reexec

Conversation

@marcusr-amd

Copy link
Copy Markdown
Collaborator

Summary

A run inherits whatever the host happens to hold — $HOME, credentials, unrelated checkouts, ambient caches — and nothing stops an optimization session from reading or depending on any of it. That makes a result hard to trust and hard to reproduce elsewhere.

HYPERLOOM_SANDBOX_MODE=bwrap re-executes the process inside bubblewrap before the CLI dispatches, so every subprocess a command later spawns is a descendant of the sandbox.

Default is off; existing runs are bit-for-bit unchanged.

Why re-exec rather than wrapping each spawn

Subprocesses are created from more than seventy call sites across src/ — benchmarks, specialists, the kernel lane, installers. A contract spread that thinly is one nobody can hold, and it would need re-checking on every new spawn site. Re-executing once covers all of them by construction and leaves those call sites untouched.

Contract

Mirrors the Codex sandbox in common/codex_session.py: validate the mode, probe the real namespace capability rather than trusting that a bwrap binary exists, and fail closed. A run that asked to be isolated and cannot be is an error, never a quiet downgrade. An unrecognised mode is rejected rather than defaulted, so a typo cannot silently disable isolation.

The venv is mounted read-only; only paths named in HYPERLOOM_SANDBOX_WRITABLE are writable. Installed packages are not a deliverable, so a write to them is a mistake worth surfacing rather than absorbing.

Four binds that encode silent failures

Each is commented in place, because none produces a useful diagnostic:

Bind What happens without it
/sys torch.cuda.device_count() returns 0. No error, no warning — ROCm reads GPU topology from /sys/class/kfd.
TMPDIR inside the sandbox SIGSEGV with no output. ROCm JIT-compiles blit kernels at device init; the compiler cannot write its temporaries. An inherited TMPDIR naming an unbound host path is enough.
Interpreter directory at the path pyvenv.cfg declares, unresolved Dangling interpreter. uv points a venv at a version-alias directory that is itself a symlink; binding the resolved target leaves the alias absent. Its parent is bound, not the bin directory — the interpreter needs its sibling lib/.
Sandbox HOME outside /home A tmpfs over the real home shadows ~/.local/share/uv/python, taking the interpreter with it.

The importing tree travels with the process too: a source checkout on PYTHONPATH sits outside the venv, and without binding it the re-executed process silently imports a different Hyperloom — whichever one the venv happens to contain. Running other code than the caller's, quietly, is worse than failing.

Scope and limits

This is an isolation boundary, not a security boundary against hostile code: without --unshare-net the network is shared, and the GPU devices are bound in deliberately.

Test plan

  • 31 unit tests — mode resolution, every bind above, the capability probe, and each path that must fail closed.
  • ruff check clean.
  • Verified on gfx1151 against a ROCm nightly venv (torch 2.14.0+rocm10.2.0a20261015): inside the sandbox ~/.ssh is unreadable and host checkouts are invisible; torch sees the GPU and a matmul runs; Ray brings up a cluster and schedules a GPU actor on the Radeon 8060S, then shuts down cleanly; writes to a declared writable root land on the host; a write into the venv is refused with Read-only file system.
  • Not verified: multi-GPU device binding, MI300 partition modes, and any distribution other than Ubuntu 24.04 with bubblewrap 0.9.0.

Note for reviewers

Everything above was measured on a single machine with one GPU. The bind list is derived from observed failures rather than from documentation, so the four entries in the table are the ones most likely to need adjusting on other hardware — and each fails silently, which is why they carry their reasoning inline.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Code metrics gate: PASSED

Judged: the 3 files this change touches. Files it does not touch never fail it; anything found there is listed under Outside this change for information.

Dimension Limit New Worse Baseline out of date Baseline entries vs base
Cyclomatic complexity fails > 20 0 0 0 73 +0
Cognitive complexity fails > 30 0 0 0 289 +0
Function length (lines) fails > 80 0 0 0 453 +0
Nested block depth fails > 5 0 0 0 27 +0
Module length (lines) warns > 800, fails > 1200 0 0 0 53 +0
Duplicated lines per file fails > 0 0 0 0 120 +0
Dead code (vulture) fails > 0 0 0 0 2 +0
Check Findings Effect
Comments 0
English only 0
Production code imports test code 0
Repeated literals 0
A1 Private name of another package 0
A8 Flag argument 0
A9 Tuple return 0
A10 Raw vocabulary comparison 0
A11 Hidden global write 0
A12 Pure forwarder 0
A12 Single-implementation abstraction 0
116 findings in files this change does not touch

Outside this change (informational): 116

Unit Dimension Baseline Now
src/hyperloom/common/process_owner.py supervise Cognitive complexity absent 38
src/hyperloom/inference_optimizer/reference_script.py render_reference_script Cognitive complexity absent 31
src/hyperloom/orchestrator/enablement/recipe/projections.py project_launch_evidence Cognitive complexity absent 36
src/hyperloom/orchestrator/phases/machine_state.py _kernel_exit Cognitive complexity absent 33
src/hyperloom/orchestrator/state/shared_state.py resolve_graded_comparison Cognitive complexity absent 34
src/hyperloom_kb/collect/mapping.py _experience Cognitive complexity absent 31
src/hyperloom_kb/schema.py FieldDeclaration.__post_init__ Cognitive complexity absent 32
src/kernelforge/cli/forge_loop_command.py _prepare_measurement_driver Cognitive complexity absent 36
src/kernelforge/cli/forge_loop_command.py _read_kb_warm_start Cognitive complexity absent 40
src/kernelforge/loop/runner.py IterationLoop._collect_session_candidate Cognitive complexity absent 44
src/kernelforge/loop/runner.py IterationLoop._rule_on_attempt Cognitive complexity absent 38
src/hyperloom/orchestrator/loop/dispatcher.py DispatcherCollaborator.run_task_registered Cyclomatic complexity absent 25
src/hyperloom_kb/tests/test_cli.py Duplicated lines per file absent 18
src/hyperloom_kb/tests/test_global_identity_and_pull.py Duplicated lines per file absent 13
src/hyperloom_kb/tests/test_local_state.py Duplicated lines per file absent 18
src/hyperloom/inference_optimizer/cli/__init__.py _reexport_session_environment Function length (lines) absent 112
src/hyperloom/inference_optimizer/cli/__init__.py _resolve_agent_lineup Function length (lines) absent 89
src/hyperloom/inference_optimizer/cli/__init__.py _resolve_run_topology Function length (lines) absent 92
src/hyperloom/inference_optimizer/cli/__init__.py _resume_session Function length (lines) absent 171
src/hyperloom/inference_optimizer/cli/__init__.py _seed_fresh_session Function length (lines) absent 183
src/hyperloom/inference_optimizer/multi_node/cli.py _restart_multinode Function length (lines) absent 107
src/hyperloom/inference_optimizer/multi_node/scripts/launch_multinode.py _build_parser Function length (lines) absent 84
src/hyperloom/inference_optimizer/multi_node/scripts/launch_multinode.py _spawn_rank_actors Function length (lines) absent 90
src/hyperloom_kb/http_service.py RequestHandler._dispatch Function length (lines) absent 91
src/hyperloom_kb/sync.py GlobalSync.pull Function length (lines) absent 86

...and 91 more; the job log lists every row.

Verdict computed by the gate scripts in /home/runner/work/_temp/code-metrics-gate, not by this tree's copy.

Thresholds and sources: [tool.hyperloom.code_metrics] in pyproject.toml. Tools: complexipy 8.0.1, jscpd 5.4.1, ruff 0.16.2, vulture 2.16.

marcusr-amd and others added 2 commits October 19, 2026 11:46
A run inherits whatever the host happens to hold: $HOME, credentials,
unrelated checkouts, ambient caches. Nothing stops an optimization session
from reading or depending on any of it, which makes a result hard to trust
and hard to reproduce somewhere else.

HYPERLOOM_SANDBOX_MODE=bwrap re-executes the process inside bubblewrap
before the CLI dispatches, so every subprocess a command later spawns is a
descendant of the sandbox. Re-exec rather than per-spawn wrapping because
subprocesses are created from more than seventy call sites -- benchmarks,
specialists, the kernel lane, installers -- and a contract spread that
thinly is one nobody can hold. The spawn sites are untouched.

The contract mirrors the Codex sandbox next door: validate the mode, probe
the real namespace capability rather than trusting that a bwrap binary
exists, and fail closed. A run that asked to be isolated and cannot be is
an error, never a quiet downgrade -- and an unrecognised mode is rejected
rather than defaulted, so a typo cannot silently disable isolation. The
default is off, so existing runs are bit-for-bit unchanged.

Four of the binds encode failures that give no useful diagnostic, and are
commented in place so nobody has to rediscover them:

  * /sys must be bound. ROCm reads GPU topology from /sys/class/kfd, and
    without it torch.cuda.device_count() returns 0 -- no error, no warning,
    simply no GPUs.
  * TMPDIR must point inside the sandbox. ROCm JIT-compiles its blit
    kernels at device init; when the compiler cannot write its temporaries
    the process dies with SIGSEGV and prints nothing at all. An inherited
    TMPDIR naming an unbound host path is enough to trigger it.
  * The interpreter distribution is bound at the path pyvenv.cfg declares,
    unresolved. uv points a venv at a version-alias directory which is
    itself a symlink; binding the resolved target leaves the alias absent
    and the interpreter unreachable. Its parent is bound, not the bin dir,
    because the interpreter needs its sibling lib/.
  * The sandbox HOME is deliberately not under /home. A tmpfs over the real
    home shadows ~/.local/share/uv/python, taking the interpreter with it.

The importing tree travels with the process too: a source checkout on
PYTHONPATH sits outside the venv, and without binding it the re-executed
process silently imports a different Hyperloom -- whichever one the venv
happens to contain. Running other code than the caller's, quietly, is worse
than failing.

The venv is mounted read-only; only paths named in
HYPERLOOM_SANDBOX_WRITABLE are writable. Installed packages are not a
deliverable, so a write to them is a mistake worth surfacing.

Verified on gfx1151 against a ROCm nightly venv: inside the sandbox
~/.ssh is unreadable and the host checkouts are invisible, while torch sees
the GPU and runs, Ray brings up a cluster and schedules a GPU actor on it,
writes to the declared root land on the host, and a write into the venv is
refused. 31 tests cover mode resolution, every bind above, the capability
probe, and each path that must fail closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The code-metrics gate fails on any literal written three or more times in a
file it judges, and this module wrote four: the mode name "off", and the
bwrap flags --tmpfs, --ro-bind and --setenv.

Naming them is the fix the gate asks for, and it reads better here anyway --
the bind list is now a sequence of named operations rather than a wall of
strings that differ by one character.

With the flag named, three consecutive single-path --ro-bind blocks were
only repetition, so they collapse into one loop over the paths. Argument
order and the resulting invocation are unchanged.

Also joins two wrapped raise statements that ruff format wanted on one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@marcusr-amd
marcusr-amd force-pushed the marcusr/sandbox-reexec branch from a74b648 to 576fcb8 Compare October 19, 2026 18:56

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant