Skip to content

Add a hyperlight-unikraft (full CPython) sandbox backend - #252

Open
danbugs wants to merge 10 commits into
mainfrom
unikraft-backend
Open

danbugs wants to merge 10 commits into
mainfrom
unikraft-backend

Conversation

@danbugs

@danbugs danbugs commented Oct 19, 2026

Copy link
Copy Markdown

Adds a third backend that runs full CPython in a Unikraft micro-VM via hyperlight-unikraft 0.18.0. The agent image includes numpy, pandas, scipy and scikit-learn. The backend follows the same Guest/GuestSandbox contract as the Wasm and HyperlightJS backends, so Sandbox(backend="unikraft") gives the same run, tools, network allowlist, /input and /output, and snapshot/restore.

  • New hyperlight-unikraft-sandbox crate:
    • a boot-time prelude provides call_tool, http_get and http_post, with large payloads chunked both ways and per-run caps on what the host holds;
    • /input and /output are host mounts. /output is checked with CapFs's own rules (sizes, links, depth, entries, special files) after every run, and before every run for what the host put there;
    • files in /output persist across runs and are cleared on snapshot restore, as in the other backends (Preserve output files across sandbox runs #250);
    • HTTP goes through the allowlist, and guest sockets are opt-in (native_network);
    • snapshot and restore cover the whole VM;
    • exit status follows CPython.
  • CapFs::validate_output and CapFs::is_same_dir, plus input_path and permissions() accessors, for backends whose guests write host directories.
  • hyperlight-unikraft brings hyperlight-host 0.18 alongside the 0.17 that hyperlight-wasm and hyperlight-js still need. No Hyperlight types cross between backends.
  • Python: Sandbox(backend="unikraft") with scratch_size and native_network, the hyperlight-sandbox[unikraft] extra, an example, and CodeExecutionTool support. allow_domain validates methods up front, as Validate queued Python network methods immediately #249 does for the other backends.
  • .NET: SandboxBackend.Unikraft, WithScratchSize, WithNativeNetwork and CodeExecutionTool support.
  • README sections and Justfile wiring. The end-to-end tests skip when the images or KVM aren't available.
  • Refreshes the stale uv.lock.
  • Behaviour change: SandboxEnvironment.heap_size and stack_size now default to None. The Wasm and JS backends still get the same values as before.

The lock had fallen behind pyproject.toml: twine is no longer a dev
dependency, and the ruff and agent-framework-devui floors moved. uv sync
--frozen does not notice; uv lock --check does.

Signed-off-by: danbugs <danilochiarlone@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Runtime correctness, Windows image discovery, and release/test integration issues remain unresolved.

9 open issues
What changed in this PR

Adds a full-CPython Unikraft backend alongside Wasm and HyperlightJS, using the existing sandbox contract.

Changes:

  • Implements VM execution, tool and HTTP bridges, filesystem checks, and snapshots.
  • Adds Python and .NET backend selection and configuration.
  • Adds tests, examples, documentation, and build/package wiring.
File Description
src/​unikraft_sandbox/​tests/​relative_output.rs Tests mount stability across directory changes.
src/​unikraft_sandbox/​tests/​python_image.rs Adds image-backed integration tests.
src/​unikraft_sandbox/​src/​prelude.py Implements the guest Python bridge.
src/​unikraft_sandbox/​src/​lib.rs Implements the Unikraft backend.
src/​unikraft_sandbox/​Justfile Adds backend development recipes.
src/​unikraft_sandbox/​examples/​basics.rs Demonstrates Rust backend usage.
src/​unikraft_sandbox/​Cargo.toml Defines the backend crate.
src/​sdk/​python/​unikraft_backend/​src/​lib.rs Adds native Python bindings.
src/​sdk/​python/​unikraft_backend/​README.md Describes the binding package.
src/​sdk/​python/​unikraft_backend/​pyproject.toml Configures Python packaging.
src/​sdk/​python/​unikraft_backend/​hyperlight_sandbox_backend_unikraft/​__init__.py Exports native binding types.
src/​sdk/​python/​unikraft_backend/​Cargo.toml Defines the Python extension crate.
src/​sdk/​python/​Justfile Wires backend builds and publishing.
src/​sdk/​python/​core/​tests/​test_unikraft_options.py Tests Python option forwarding.
src/​sdk/​python/​core/​tests/​test_network_permissions.py Extends network validation coverage.
src/​sdk/​python/​core/​README.md Documents the installation extra.
src/​sdk/​python/​core/​pyproject.toml Adds the Unikraft dependency extra.
src/​sdk/​python/​core/​hyperlight_sandbox/​__init__.py Adds backend selection and options.
src/​sdk/​python/​core/​examples/​unikraft_basics.py Demonstrates Python backend usage.
src/​sdk/​dotnet/​README.md Documents .NET backend usage.
src/​sdk/​dotnet/​ffi/​src/​lib.rs Adds native dispatch and configuration.
src/​sdk/​dotnet/​ffi/​Cargo.toml Adds the backend dependency.
src/​sdk/​dotnet/​core/​Tests/​HyperlightSandbox.Tests/​UnikraftBackendTests.cs Tests builder validation.
src/​sdk/​dotnet/​core/​Tests/​HyperlightSandbox.Tests/​CodeExecutionToolTests.cs Tests tool integration.
src/​sdk/​dotnet/​core/​PInvoke/​SafeNativeMethods.cs Declares new native configuration calls.
src/​sdk/​dotnet/​core/​Extensions.AI/​CodeExecutionTool.cs Adds Unikraft initialization code.
src/​sdk/​dotnet/​core/​Api/​SandboxBuilder.cs Adds backend-specific builder options.
src/​sdk/​dotnet/​core/​Api/​SandboxBackend.cs Adds the Unikraft enum value.
src/​sdk/​dotnet/​core/​Api/​Sandbox.cs Applies native backend settings.
src/​hyperlight_sandbox/​src/​network.rs Exposes granted network permissions.
src/​hyperlight_sandbox/​src/​cap_fs.rs Adds directory identity and output validation.
README.md Documents backend capabilities and differences.
pyproject.toml Registers the Python workspace package.
Justfile Integrates backend build and test recipes.
Cargo.toml Registers crates and dependencies.
Cargo.lock Locks the expanded dependency graph.

Review effort: Balanced

Comment thread src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/CodeExecutionToolTests.cs Outdated
Comment thread src/unikraft_sandbox/Cargo.toml
Comment thread src/unikraft_sandbox/src/lib.rs Outdated
Comment thread src/unikraft_sandbox/src/lib.rs Outdated
Comment thread src/unikraft_sandbox/src/lib.rs Outdated
Comment thread src/unikraft_sandbox/src/lib.rs Outdated
Comment thread src/unikraft_sandbox/src/prelude.py Outdated
Comment thread src/unikraft_sandbox/tests/python_image.rs
A backend that mounts host directories into its guest needs the input
directory's host path, as it already gets the output one; one that
enforces the allowlist on raw sockets needs the permissions granted.

CapFs::validate_output rescans the output directory after such a guest
wrote it directly and applies the rules CapFs applies to writes made
through it (limits, no symbolic or hard links, traversal bounds), plus
no special files, which only a direct writer can make.
CapFs::is_same_dir tells whether a path still names the directory CapFs
opened, for a backend that hands the guest that path.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
hyperlight-unikraft-sandbox runs full CPython in a Unikraft micro-VM,
from the published python, python-shell and agent rootfs images (the
agent image adds numpy, pandas, scipy and scikit-learn), behind the same
Guest / GuestSandbox contract as the Wasm and HyperlightJS backends.

A prelude loaded at boot gives guest code call_tool, http_get and
http_post as the Wasm Python guest has them, and the guest function
every run calls. Requests and replies over the 64 KiB host call limit
cross it in pieces, output in chunks bounded by their encoded size; the
host holds at most 64 MiB of unread pieces per run. Exit statuses are a
CPython process's, 0 to 255, leaving -1 to a failed guest. HTTP goes
through the host allowlist; guest sockets are opt-in (native_network)
and allowed by host name only, an unresolvable name being left out
rather than failing every run. Snapshot and restore cover the whole
micro-VM.

/input and /output are hostfs mounts of paths resolved at build, and
CapFs checks /output after each run (CapFs::validate_output): a run that
breaks its rules fails. /output is cleared before each run and after a
failed check, through the handle CapFs opened so that the working
directory cannot redirect it, of every file and link and of the
directories the guest made, however deep; the host's directories are
kept. hostfs opens a mount when the guest boots, so before each boot the
paths are checked to still be the directories CapFs opened: a replaced
one fails the boot until it is put back.

It builds against hyperlight-unikraft 0.18.0 and its images. That
release's hyperlight-host 0.18 links beside the 0.17 hyperlight-wasm
0.15 and hyperlight-js 0.4 still need: hyperlight-sandbox shares no
hyperlight type between backends. The guest's own output stays out of
the host process (echo_output(false)), is capped like the rest, and its
standard error, the kernel's messages included, goes to the run's stderr
when written in whole lines. Child processes, threads and kills work in
the guest as of 0.18, and the agent image carries the runtime marker
hluk sizes it by.

The end-to-end tests skip themselves when the images are not cached
(just unikraft pull-images).

Signed-off-by: danbugs <danilochiarlone@gmail.com>
Sandbox(backend="unikraft") (also "hyperlight-unikraft" or "hluk") runs
code in a hyperlight-unikraft micro-VM through the new
hyperlight-sandbox-backend-unikraft package, installed with
hyperlight-sandbox[unikraft]. module names a published image the hluk
CLI has cached ("agent" by default, or "python"), module_path any rootfs
image; not both. scratch_size sets the guest memory, and native_network
lets guest sockets reach the allow_domain hosts. These are refused for
the other backends, and heap_size and stack_size for this one, before
any backend is imported; SandboxEnvironment leaves heap_size and
stack_size unset by default to allow it. CodeExecutionTool takes the
backend like the others.

allow_domain validates its methods when called, before or after the
first run, as the other backends do since #249; the network permission
tests cover the backend where it is installed, its python image cached
and a hypervisor present.

The Justfile builds, packages and publishes the backend with the other
two, and unikraft-examples runs unikraft_basics.py.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
SandboxBackend.Unikraft (FFI backend 2) builds a hyperlight-unikraft
sandbox from the rootfs image given as the module path. WithScratchSize
(hyperlight_sandbox_set_scratch_size) sets its guest memory, at least 1
MiB, in place of the heap and stack sizes, which are refused for it as
in the Python SDK; unset, the image's tested size applies.
WithNativeNetwork (hyperlight_sandbox_set_native_network) turns on guest
sockets, as native_network does in Python. CodeExecutionTool initialises
it as it does the Wasm Python guest. Its sandbox is boxed in the FFI's
backend enum, being several times the size of the others.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
How to run it from Rust and Python, the images it takes, and how it
differs from the other backends: one guest memory size, host-mounted
/input and /output checked after each run and cleared before it,
socket-level networking by host name only, data exchanged in pieces past
the 64 KiB host call limit, CPython exit statuses, and
hyperlight-unikraft's guest limits.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
FilesystemLimitsConfiguration? filesystemLimits,
SandboxBackend backend = SandboxBackend.Wasm)
SandboxBackend backend = SandboxBackend.Wasm,
bool nativeNetwork = false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how this would work but I would prefer that the hl-unikraft network goes through the wasi interface (same for the filesystem going through wasi-file).

In hl-js I create a custom guest handler that did this work

const RUN_HANDLER_SCRIPT: &str = r#"
. I would imagine we would do something similar in unikraft backend.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Unikraft guest's network and filesystem now go through the sandbox's own policy, as in the hl-js handler (e942b66, a7609db; hluk side in hyperlight-dev/hyperlight-unikraft#179 and #181). For now this builds against a pinned hluk rev; hluk v0.19.0 will include these changes and the PR will move to it.

Pinned to 47bc9e2, which has NetworkPolicy::Filter
(hyperlight-dev/hyperlight-unikraft#179) and FsBackend (#181), until
they are released; then this becomes a version bump. The v0.18.0
images run on it unchanged.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
With native_network, guest sockets were allowed to the allow_domain
hosts by name, resolved into an AllowList at boot, and an allowlist
change re-booted the guest from a snapshot to take effect. A name that
did not resolve then was left out until the next change.

hyperlight-unikraft's NetworkPolicy::Filter asks the embedder about each
DNS question and connect instead, so the guest's sockets are checked
against the NetworkPermissions http_get uses, as they are at that
moment: a TCP connection to an entry's host on its port (the one the URL
names, else its scheme's). An allow_domain applies from the next
connect, nothing is resolved up front, and the re-boot and its
directory checks go.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
A hyperlight-unikraft guest asks the host for each file operation by
path, not through WASI descriptors. stat_path, read_path, write_path,
create_dir_path, remove_path, truncate_path, list_path and rename_path
serve it by the rules the descriptor operations apply: the directory
permissions, and for the output the filesystem limits (checked before
the host directory changes, and kept as files are removed, truncated or
renamed over), no hard-linked files, and the bounds the output scan
walks (64 levels, a directory moved included, and 100,000 entries).
Only regular files are read, so a FIFO cannot block the call. Errors
are io::Errors, as the guest's file calls should see them.

Signed-off-by: danbugs <danilochiarlone@gmail.com>
The guest's /input and /output are now hostfs mounts whose operations
CapFs serves, through hyperlight-unikraft's FsBackend, instead of host
directories the guest writes directly and CapFs checks after the run.
A write past the filesystem limits, a link, or a path deeper than the
output scan walks now fails in the guest (EFBIG, EDQUOT, EACCES,
EINVAL) before the host directory changes, as in the other backends.

The check before and after each run stays, for what no single operation
shows: what the host changes in /output during a run, and more than
100,000 entries. The guest no longer holds the directories by path, so
the check that they were not replaced before boot goes, and with it
CapFs::is_same_dir and CapFs::input_path, which only it used.

Signed-off-by: danbugs <danilochiarlone@gmail.com>

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.

3 participants