The original PVM kernel tree was based on Linux 6.12.x, while current Linux master had reached 7.2. This note records the commands I used to build, package, and boot the forward port. The resulting tree is available in the pvm-720 branch.

PVM in short

PVM (Pagetable-based Virtual Machine) is a KVM guest hypervisor designed for running nested workloads without requiring VMX or SVM. Essentially native or nested virtualization HW functionality is NOT required! It uses a small shared region for privilege transitions and efficient shadow page tables for memory virtualization. The design is described in the SOSP '23 paper PVM: Efficient Shadow Paging for Deploying Secure Containers in Cloud-native Environments. The original code is in the virt-pvm/linux repository.

Get the port

Prebuilt host-kernel Debian packages and a guest-kernel binary are also available for Ubuntu-ish systems from the latest successful PVM kernels GitHub Actions run.

On Ubuntu 26.04:

sudo apt update
sudo apt install -y \
    bc bison build-essential flex git libelf-dev libssl-dev

sudo apt install -y libz3-dev cmake

git clone --branch pvm-720 --single-branch \
    https://github.com/kholia/linux.git linux-pvm
cd linux-pvm

git log -1 --oneline
make --silent kernelversion

The port updates the x86 entry code, KVM capability and MSR handling, PFN cache interfaces, early page tables, linker layout, and objtool annotations. It also adds a PVM KVM selftest, a minimal guest config, a config validator, and Ubuntu 26.04 GitHub Actions builds.

Build the minimal guest kernel

Start with tinyconfig, merge the PVM guest fragment, resolve Kconfig dependencies, and only then run the validator:

make ARCH=x86 tinyconfig

scripts/kconfig/merge_config.sh -m \
    .config kernel/configs/virt_pvm_openshell.config

make ARCH=x86 olddefconfig
scripts/check-pvm-config.sh .config

make --silent ARCH=x86 -j"$(nproc)" bzImage
ls -lh arch/x86/boot/bzImage

The order matters. Running scripts/check-pvm-config.sh before olddefconfig can report selected or hidden options incorrectly.

The final kernel is built-in-only: there is no module loader or initramfs. It contains the virtio, ext4, namespace, cgroup, networking, netfilter, seccomp, and hardening features required by the guest workload.

Build an installable PVM host kernel

The host build starts with the default config of the running Ubuntu 26.04 kernel and merges the PVM host fragment. Debug information is disabled to keep the CI build and packages manageable. Ubuntu-specific certificate paths are also cleared because those files are not present in the upstream Linux tree.

Install the packaging dependencies:

sudo apt install -y --no-install-recommends \
    bc bison build-essential cpio debhelper dpkg-dev dwarves fakeroot \
    flex kmod libdw-dev libelf-dev libssl-dev lz4 python3 rsync xz-utils zstd

Create the host config:

ubuntu_config="/boot/config-$(uname -r)"
test -r "$ubuntu_config"

cp "$ubuntu_config" ubuntu-26.04-base.config
cp "$ubuntu_config" .config

scripts/config --file .config \
    --set-str LOCALVERSION "" \
    --disable LOCALVERSION_AUTO \
    --set-str SYSTEM_TRUSTED_KEYS "" \
    --set-str SYSTEM_REVOCATION_KEYS "" \
    --disable DEBUG_INFO \
    --enable DEBUG_INFO_NONE \
    --disable DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT \
    --disable DEBUG_INFO_DWARF4 \
    --disable DEBUG_INFO_DWARF5 \
    --disable DEBUG_INFO_BTF

scripts/kconfig/merge_config.sh -m \
    .config kernel/configs/virt_pvm_test_host.config

make ARCH=x86 olddefconfig

grep -x 'CONFIG_KVM=y' .config
grep -x 'CONFIG_KVM_PVM=y' .config
grep -x 'CONFIG_KVM_WERROR=y' .config
grep -x 'CONFIG_DEBUG_INFO_NONE=y' .config
! grep -Eq '^CONFIG_KVM_(INTEL|AMD)=[ym]$' .config

Build the Debian packages:

export KBUILD_BUILD_HOST=local
export KBUILD_BUILD_USER=pvm
export KDEB_CHANGELOG_DIST=resolute
export KDEB_SOURCENAME=linux-pvm
export LOCALVERSION=-pvm

kernel_version="$(make --silent kernelversion | sed 's/-rc/~rc/')"
export KDEB_PKGVERSION="${kernel_version}+pvm.1"

make --silent ARCH=x86 -j"$(nproc)" bindeb-pkg

ls -lh ../linux-image-*.deb ../linux-headers-*.deb

Install both packages:

sudo apt install ../linux-image-*.deb ../linux-headers-*.deb
sudo update-grub

PVM currently requires pti=off on the host. Add it to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub, then regenerate GRUB and reboot:

sudoedit /etc/default/grub
sudo update-grub
sudo reboot

After rebooting:

uname -r
grep '^CONFIG_KVM_PVM=y' "/boot/config-$(uname -r)"
ls -l /dev/kvm

KVM_PVM is built into this host kernel, so there is no kvm_pvm module to load.

Run the PVM selftest

cd linux-pvm

make -C tools/testing/selftests/kvm \
    -j"$(nproc)" x86/pvm_test

./tools/testing/selftests/kvm/x86/pvm_test

The test covers PVM availability, virtual MSRs, register access, and invalid address handling. Run it on the newly built host kernel; an older 6.12 PVM host does not contain all the fixes tested by the new binary.

Download an Ubuntu 26.04 guest image

Install QEMU, qboot, and the cloud-image helper:

sudo apt install -y \
    cloud-image-utils curl qemu-system-data qemu-system-x86

Download the current Resolute cloud image and verify it against Ubuntu's published checksum file:

curl -fLO \
    https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img
curl -fLO \
    https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS

grep 'resolute-server-cloudimg-amd64.img$' SHA256SUMS | sha256sum -c -

Create a small NoCloud seed so the ubuntu user can log in on the serial console. This password is only suitable for an isolated test VM:

cat > user-data <<'EOF'
#cloud-config
password: password
chpasswd: { expire: false }
ssh_pwauth: true
EOF

cloud-localds user-data.img user-data

Boot the new bzImage with qboot

SeaBIOS rejected an early write into its protected pc.bios region during testing. qboot is small, fast, and works correctly for direct kernel boot. Its ROM is installed by Ubuntu's qemu-system-data package.

export ROOTFS="$PWD/resolute-server-cloudimg-amd64.img"
export SEED="$PWD/user-data.img"

qemu-system-x86_64 \
    -machine q35,accel=kvm \
    -cpu host \
    -smp 2 \
    -m 512M \
    -bios /usr/share/qemu/qboot.rom \
    -kernel arch/x86/boot/bzImage \
    -append "console=ttyS0 root=/dev/vda1 rw rootwait pti=off panic=-1 fstab=no systemd.mask=lvm2-monitor.service systemd.mask=multipathd.service systemd.mask=multipathd.socket systemd.mask=chrony.service" \
    -drive file="$ROOTFS",if=none,format=qcow2,id=root \
    -device virtio-blk-pci,drive=root \
    -drive file="$SEED",if=virtio,format=raw,readonly=on \
    -snapshot \
    -nographic \
    -no-reboot \
    -nic user,model=virtio-net-pci

The expected end state is:

Reached target multi-user.target
Reached target graphical.target

Ubuntu 26.04 LTS ubuntu ttyS0

ubuntu login: ubuntu
Password:


Welcome to Ubuntu 26.04 LTS (GNU/Linux 7.2.0+ x86_64)
...

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

ubuntu@ubuntu:~$ sudo dhcpcd enp0s2

ubuntu@ubuntu:~$ curl ifconfig.me/all
ip_addr: <ip-address>
user_agent: curl/8.18.0

Use Ctrl-a x to leave QEMU. -snapshot ensures that the downloaded cloud image is not modified.

BONUS: OpenShell + PVM demo

See https://github.com/kholia/OpenShell/tree/Support-for-PVM for details.

$ export PATH=/usr/sbin:$PATH
$ openshell-gateway

$ openshell sandbox create --name pvm-demo --from base --no-tty -- uname -a
Created sandbox: pvm-demo
 Sandbox allocated (0s)
Linux pvm-demo 7.2.0+ #1 SMP PREEMPT Mon Aug 17 04:09:20 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
$ cat ~/.config/openshell/gateway.toml  # insecure, only for development
[openshell]
version = 1

[openshell.gateway]
# Bind to loopback only.  The Docker driver adds an extra listener on the
# bridge interface automatically so sandbox containers can reach the gateway.
log_level           = "info"
compute_drivers     = ["vm"]
disable_tls = true

[openshell.gateway.gateway_jwt]
signing_key_path = "/home/user/.local/state/openshell/tls/jwt/signing.pem"
public_key_path = "/home/user/.local/state/openshell/tls/jwt/public.pem"
kid_path = "/home/user/.local/state/openshell/tls/jwt/kid"
gateway_id = "openshell"
ttl_secs = 0

[openshell.gateway.auth]
allow_unauthenticated_users = true

[openshell.drivers.vm]
pvm_kernel = "/home/user/vmlinux-guest"
pvm_firmware = "/home/user/qboot.rom"
$ openshell sandbox create --name pvm-demo --from base --policy examples/sandbox-policy-quickstart/policy.yaml

sandbox@pvm-demo:~$ curl https://api.github.com/zen
Half measures are as bad as nothing at all.

Yep - the full PVM path is working: qboot boot, QEMU networking, authenticated supervisor startup, policy enforcement, DNS, and GitHub HTTPS egress!

OpenShell - Run Custom Workloads

openshell sandbox create \
    --name pvm-rootfs-dev \
    --from examples/pvm-custom-rootfs \
    --policy examples/pvm-custom-rootfs/policy.dev-only.yaml
openshell forward start 0.0.0.0:6080 pvm-rootfs-dev

See https://github.com/kholia/OpenShell/tree/Support-for-PVM for more details.

OpenShell - GUI workloads

$ openshell sandbox connect
...

# Clean up anything left from previous attempts.
pkill -f websockify 2>/dev/null || true
pkill -f x11vnc 2>/dev/null || true
pkill -f openbox 2>/dev/null || true
pkill -f Xvfb 2>/dev/null || true

# Runtime directories.
export HOME=/sandbox/home
export XDG_RUNTIME_DIR=/tmp/runtime
export XDG_CONFIG_HOME=/sandbox/config
export XDG_CACHE_HOME=/sandbox/cache
export TMPDIR=/tmp
export DISPLAY=:99

mkdir -p \
    "$HOME" \
    "$XDG_RUNTIME_DIR" \
    "$XDG_CONFIG_HOME" \
    "$XDG_CACHE_HOME"

chmod 700 "$XDG_RUNTIME_DIR"

# 1. Virtual X11 display.
Xvfb :99 \
    -screen 0 1920x1080x24 \
    -nolisten tcp \
    -ac \
    >/tmp/xvfb.log 2>&1 &

sleep 1

# Verify X is alive.
xdpyinfo -display :99 >/dev/null && echo "Xvfb OK"

# 2. Lightweight window manager.
DISPLAY=:99 openbox-session \
    >/tmp/openbox.log 2>&1 &

sleep 1

# 3. VNC server. Keep it bound to localhost.
DISPLAY=:99 x11vnc \
    -display :99 \
    -localhost \
    -rfbport 5900 \
    -forever \
    -shared \
    -nopw \
    >/tmp/x11vnc.log 2>&1 &

sleep 1

# 4. noVNC/WebSocket frontend.
websockify \
    --web=/usr/share/novnc \
    127.0.0.1:6080 \
    127.0.0.1:5900 \
    >/tmp/websockify.log 2>&1 &

sleep 1

# Verify ports.
echo "=== listening ports ==="
ss -lntp | grep -E '5900|6080' || true
tsh ssh -L 5000:localhost:6080 user@remote-dev-machine

Idea: a tiny Codex development cloud

A single AWS VM running the PVM-enabled kernel could host several isolated OpenShell sandboxes, each running a Codex development environment for one remote user.

A small control plane would create a sandbox from a common base image, attach a persistent workspace, inject the user's SSH key, apply CPU, memory, disk, and network-policy limits, and expose SSH through a bastion or a per-sandbox port. Users would see an ordinary remote Linux development machine while the host handles provisioning, suspension, snapshots, cleanup, and audit logs.

So yes: this is a small orchestration platform. OpenShell and PVM provide the isolation and policy layer; the missing piece is mostly lifecycle and identity management. It would be a useful team-sized system or prototype, though one AWS VM remains a capacity ceiling and single point of failure. The VM itself would be managed by IT; developers would have SSH access only to their assigned sandboxes, not to the host.

Quickstart Guide

On the target box:

sudo usermod -aG docker $USER

sudo usermod -aG kvm $USER

wget https://github.com/kholia/linux-builds/raw/refs/heads/master/pvm-host-kernel-debs.zip

wget https://github.com/kholia/linux-builds/raw/refs/heads/master/pvm-full-guest-kernel.zip

unzip pvm-host-kernel-debs.zip

unzip pvm-full-guest-kernel.zip

mv arch/x86/boot/bzImage vmlinux-guest

sudo apt install ./*.deb

Reboot the target box:

sudo reboot

Re-login to the target box:

$ uname -a
Linux remote-box 7.2.0-pvm #1 SMP PREEMPT_DYNAMIC Wed Aug 19 12:31:05 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux

$ sudo cat /etc/modules-load.d/pvm.conf  # create this file
kvm-pvm

The host box is now all set to run PVM guests!

Install other components and OpenShell:

(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
	&& sudo mkdir -p -m 755 /etc/apt/keyrings \
	&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
	&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
	&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
	&& sudo mkdir -p -m 755 /etc/apt/sources.list.d \
	&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
	&& sudo apt update \
	&& sudo apt install gh -y
sudo apt install -y libz3-dev cmake qemu-system-x86 \
  cloud-image-utils curl qemu-system-data

git clone https://github.com/kholia/OpenShell.git -b Support-for-PVM

curl https://mise.run | sh

echo "eval \"\$(/home/dkholia/.local/bin/mise activate bash)\"" >> ~/.bashrc

source ~/.bashrc

cd ~/OpenShell

mise trust

mise install

mise run vm:setup
mise run vm:supervisor

test -s target/vm-runtime-compressed/openshell-sandbox.zst
test -s target/vm-runtime-compressed/umoci.zst

OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="$PWD/target/vm-runtime-compressed" \
mise exec -- cargo build --release -p openshell-driver-vm

OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="$PWD/target/vm-runtime-compressed" \
OPENSHELL_DEB_VERSION="0.0.0-local" \
mise run package:deb:install
sudo ln -s /home/$USER /home/user

mkdir -p ~/.config/openshell

/usr/bin/openshell-gateway generate-certs \
  --output-dir "$HOME/.local/state/openshell/tls" \
  --server-san host.openshell.internal

test -s "$HOME/.local/state/openshell/tls/jwt/signing.pem"
test -s "$HOME/.local/state/openshell/tls/jwt/public.pem"
test -s "$HOME/.local/state/openshell/tls/jwt/kid"

$ cat ~/.config/openshell/gateway.toml  # insecure, only for development, create this file
[openshell]
version = 1

[openshell.gateway]
# Bind to loopback only.  The Docker driver adds an extra listener on the
# bridge interface automatically so sandbox containers can reach the gateway.
log_level           = "info"
compute_drivers     = ["vm"]
disable_tls = true

[openshell.gateway.gateway_jwt]
signing_key_path = "/home/user/.local/state/openshell/tls/jwt/signing.pem"
public_key_path = "/home/user/.local/state/openshell/tls/jwt/public.pem"
kid_path = "/home/user/.local/state/openshell/tls/jwt/kid"
gateway_id = "openshell"
ttl_secs = 0

[openshell.gateway.auth]
allow_unauthenticated_users = true

[openshell.drivers.vm]
pvm_kernel = "/home/user/vmlinux-guest"
pvm_firmware = "/usr/share/qemu/qboot.rom"

We are now ready to launch OpenShell sandboxes!

In one terminal:

sudo modprobe kvm-pvm

export PATH=/usr/sbin:$PATH
openshell-gateway

In another terminal:

openshell gateway add http://127.0.0.1:17670
cd ~/OpenShell

openshell sandbox create \
    --name pvm-rootfs-dev \
    --from examples/pvm-custom-rootfs \
    --policy examples/pvm-custom-rootfs/policy.dev-only.yaml

At the end of this semi-long process you should see something like:

✓ Sandbox allocated (0s)
sandbox@pvm-rootfs-dev:~$

sandbox@pvm-rootfs-dev:~$ uname -a
Linux pvm-rootfs-dev 7.2.0-g0053662c3879 #1 SMP PREEMPT_DYNAMIC Wed Aug 19 11:52:00 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux

Success ;)

References