Skip to content

test: close end-to-end testing gaps for nvx.py sandbox - #437

Merged
Pedro Henrique Penna (ppenna) merged 3 commits into
devfrom
agents/fix-e2e-testing-nvxpy-sandbox
Oct 8, 2026
Merged

Pedro Henrique Penna (ppenna) merged 3 commits into
devfrom
agents/fix-e2e-testing-nvxpy-sandbox

Conversation

@ppenna

@ppenna Pedro Henrique Penna (ppenna) commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #229.

The public nvx.py sandbox interface lacked deterministic end-to-end coverage of its managed lifecycle contract and of the security profile that it promises. Since #229 was filed, managed-exec-config began driving provision -> start -> exec -> stop -> deprovision through the CLI for request-scoped execution options (timeouts, nonzero exits, stdout and stderr, typed outcome reports), and scripts/test_control_session.py joined both validate-nvx invocations. This PR closes the remaining gaps.

Three commits:

  1. guest: verify the sandbox security profile in the smoke probe. /sbin/nvx-sandbox-smoke checked only the UID/GID, the Ubuntu identity, and one /tmp write. Before any share check, it now requires the fixed identity as the real, effective, saved, and file-system IDs with no supplementary groups; empty inheritable, permitted, effective, bounding, and ambient capability sets and NoNewPrivs: 1; mount, PID, and UTS namespaces other than the initial ones (Linux 6.18 fixes their inode numbers in its UAPI, enum init_ns_ino, including the mount namespace's); exactly one, non-propagating mount at /, /proc, /sys, /dev, /dev/pts, and /dev/shm, with an overlay root over the image layers whose upper directory is on scratch, read-only sysfs, and a private /dev with exactly the runtime's entries; the agent-owned /container cgroup; and image files that the workload cannot modify. With limits MEMORY_MAX PIDS_MAX, it requires the memory controller to kill an allocation of twice MEMORY_MAX with SIGKILL and the pids controller to stop the workload at PIDS_MAX processes. The one-shot smoke step on every backend now runs it with --memory-max 33554432 --pids-max 8, and the live-share steps repeat the profile checks.
  2. test: drive the public managed sandbox lifecycle end to end. A new explicit sandbox-lifecycle scenario of test-microvm runs nvx.py sandbox as subprocesses against the real kernel, Alpine control initramfs, Ubuntu EROFS layer, and fresh ext4 scratch copies. It covers identity admission (a root identity rejected by the CLI; an identity that the image lacks refused by the guest in one-shot and managed mode), every fail-closed transition (operations on unprovisioned state, repeated provision, exec and stop before start and after stop, repeated start, deprovision while running, stale runtime state after OpenVMM is killed, and a foreign file in the state directory), warm-guest state across requests with whitespace-bearing arguments and across a stop and restart, the security probe after earlier requests, the 1 MiB output bound (the host must receive more than 1 MiB less one 32 KiB agent read and at most 1 MiB) and outcome reports, and the refusal of an existing report path before the workload runs. After each step, it requires exactly the expected OpenVMM processes for its fixture (found by their arguments) and the expected state-directory entries, and it requires that the control socket or named pipe goes away, the EROFS layer's digest never changes, and the scratch image survives deprovision. On failure, it stops what it started, kills any OpenVMM process that names its fixture, and keeps bounded evidence: command observations without workload output, the runtime record, OpenVMM logs, outcome reports, and the probe transcript, each capped at its last 64 KiB, but never the control capability. CI runs it after managed-exec-config on KVM, MSHV, and WHP.
  3. ci: require every script test module in CLI validation. A unit test requires both validate-nvx steps to name exactly the scripts/test_*.py modules, so a new module cannot silently skip CI as test_control_session.py once did.

doc/run.md, doc/ci.md, doc/usage.md, doc/design/validation.md, doc/contribute.md, and the adversarial-testing design describe the resulting coverage. #102 (adaptive adversarial testing) and #216 (sandbox virtio-fs acceptance) stay independent.

Acceptance criteria

Criterion Coverage
CI executes the complete public managed lifecycle on KVM, MSHV, and WHP managed-exec-config and sandbox-lifecycle in the public acceptance step of every microVM job; a unit test pins the wiring
At least two managed execs prove filesystem state survives in the warm guest a write request and later read requests, before and after a refused deprovision, and after a stop and restart
Timeout, nonzero exit, stdout/stderr, and outcome reports through nvx.py sandbox exec managed-exec-config, plus the output-limit bound and the refusal of an existing report path in sandbox-lifecycle
Invalid transitions fail explicitly without leaked OpenVMM process, socket/pipe, capability, or runtime record every transition above, with process scans, endpoint checks, and state-directory checks after each
Stop and deprovision remove only NVX-owned state; EROFS and scratch stay intact exact state entries after stop, a foreign file that deprovision keeps, the layer's SHA-256, and the scratch image's survival
Deterministic guest probe for identity, namespace, capability, no_new_privs, cgroup, lower-layer, and scratch invariants commit 1
scripts/test_control_session.py runs in the Linux and Windows CLI validation jobs already the case; commit 3 guards it
Existing one-shot smoke coverage passes on KVM, MSHV, and WHP validated on bare metal below

Validation

Local (Windows):

  • ruff check, ruff format --check, and pyright (strict) for Linux and Windows.
  • The pinned shellcheck and shfmt images over every script that check-quality lists, and actionlint on the changed workflow (no new findings; its four SC2012 infos predate this PR).
  • The full validate-nvx command set. One existing test, test_materialize_kernel_provenance_inputs_bypasses_mutable_index, fails only on this workstation, whose global Git configuration requires SSH commit signing; it passes on the Linux host below.
  • The probe's shell tests and the Linux-only process-scan test in a Linux container, and the probe's limit checks in Docker with real cgroup limits, enforced and not.
  • Each commit passes ruff and its tests on its own.

Bare metal, in fresh clones of this branch: OpenVMM, kernel, and initramfs from v0.1.0-dev.8de93bcd226c (same OpenVMM pin, 0eccda895), the Ubuntu distro layer rebuilt from this branch with CI's Docker target, and the scratch template made as CI makes it:

Host Platform managed-exec-config + sandbox-lifecycle Workflow sandbox steps, run verbatim
prometheus32 linux-kvm, bare metal pass, 19 s 6/6 pass (one-shot with limits, live share, managed live share, caller-owned share, concurrent shares, access policy)
prometheus30 linux-mshv, bare metal pass, 20 s 6/6 pass
prometheus28 windows-whp, bare metal pass, 44 s 5/5 pass

After the review fixes, the acceptance passed again on all three hosts at d084db699, with the KVM layer rebuilt; the final head differs from it only in a Linux unit test. The forwarded output totals were 1,032,192, 1,048,576, and 1,040,384 bytes, so an exact 1 MiB check would be flaky. prometheus32 also passed the full Linux validate-nvx set (786 tests). On its guest kernel, PID 1's namespaces are mnt:[4026531832], pid:[4026531836], and uts:[4026531838], the constants that the probe compares against. No OpenVMM process or fixture remained after any run.

CI on the pre-review head bc8ba458c passed every job, and the KVM, MSHV, and WHP microVM jobs logged the sandbox-lifecycle scenario and the probe's LIMITS-OK and PROFILE-OK markers.

Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:03

Copilot AI left a comment

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.

🟡 Changes recommended

The PID-limit probe miscounts container processes, and copied failure evidence is not bounded as documented.

3 open findings
What changed in this PR

Adds deterministic end-to-end coverage for the public managed sandbox lifecycle and security profile, closing #229.

Changes:

  • Expands the guest security and resource-limit probe.
  • Adds lifecycle acceptance and regression tests across all backends.
  • Updates CI wiring, validation guards, and documentation.
File Description
scripts/​test_nvx_tools.py Tests CI wiring and smoke configuration.
scripts/​test_microvm_tests.py Tests lifecycle acceptance and cleanup.
scripts/​nvx_tools/​sandbox_lifecycle.py Exposes runtime-process validation.
scripts/​nvx_tools/​sandbox_lifecycle_tests.py Implements lifecycle acceptance.
scripts/​nvx_tools/​microvm_tests.py Registers and dispatches the scenario.
scripts/​nvx_tools/​managed_exec_tests.py Shares artifact and outcome helpers.
guest/​common/​nvx-sandbox-smoke Expands security-profile checks.
doc/​usage.md Documents explicit scenarios.
doc/​run.md Documents lifecycle and probe contracts.
doc/​design/​validation.md Describes validation coverage.
doc/​design/​copilot-adversarial-testing.md Identifies the deterministic baseline.
doc/​contribute.md Documents scenario locations.
doc/​ci.md Describes backend acceptance coverage.
.github/​workflows/​run-nvx-microvm-tests.yml Runs lifecycle and limit checks in CI.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread guest/common/nvx-sandbox-smoke Outdated
Comment thread scripts/nvx_tools/sandbox_lifecycle_tests.py Outdated
Comment thread scripts/nvx_tools/sandbox_lifecycle_tests.py
Part of #229.

/sbin/nvx-sandbox-smoke checked only the workload's UID and GID, its
Ubuntu identity, and one scratch-backed /tmp write, so CI never verified
most of what the sandbox profile promises: no capabilities,
no_new_privs, private namespaces, the private device view, read-only
sysfs, and enforced memory.max and pids.max.

Profile
-------

Before any share check, the probe now requires:

- UID and GID 65534 as the real, effective, saved, and file-system IDs,
  no supplementary groups, and HOME=/nonexistent;
- empty inheritable, permitted, effective, bounding, and ambient
  capability sets and NoNewPrivs: 1;
- mount, PID, and UTS namespaces other than the initial ones, in which
  the agent runs. Linux 6.18 fixes the initial namespaces' inode
  numbers in its UAPI (enum init_ns_ino), the mount namespace's
  included. PID 1 of the workload's PID namespace must run as the
  workload, and the host name must be the configured one;
- exactly one mount at /, /proc, /sys, /dev, /dev/pts, and /dev/shm,
  none of which propagates: an overlay root whose lower directories are
  the image layers and whose upper directory is on scratch,
  nosuid,nodev,noexec procfs, read-only sysfs, and a /dev tmpfs that
  holds exactly the runtime's thirteen entries, with its own devpts
  instance. Each managed exec enters its own namespaces before it
  mounts these, so a request that mounted into the agent's namespace
  would stack its mounts on those of earlier requests;
- the agent-owned /container cgroup; and
- image files that the workload cannot modify, and a /tmp write that
  lands in the overlay.

Each failure prints an NVX-UBUNTU-SANDBOX-FAIL line that names the
broken invariant and exits nonzero.

Limits
------

`limits MEMORY_MAX PIDS_MAX`, given as sandbox arguments, checks that the
memory controller kills an allocation of twice MEMORY_MAX with SIGKILL
while a 1 MiB allocation succeeds, and that the pids controller lets the
workload run PIDS_MAX processes, itself included. A subshell starts
sleepers until a fork fails, and its exit trap kills and reaps them and
records how many started, which must be PIDS_MAX - 2. A bound stops a
subshell whose forks never fail, and twice MEMORY_MAX must fit the
guest's free memory, so an unenforced limit fails the check instead of
the guest.

CI
--

The one-shot Ubuntu smoke test on KVM, MSHV, and WHP now passes
--memory-max 33554432 and --pids-max 8 and runs the probe with
`limits 33554432 8`. The live-share runs repeat the profile checks.

Tests
-----

Shell unit tests run the probe's functions against fixture status,
mountinfo, cgroup, command-line, and host-name files, with id and
readlink stubbed. Valid fixtures pass, and each broken invariant fails
with its message, including stacked and propagating mounts, a writable
sysfs, an upper directory off scratch, a host devtmpfs, and the initial
namespaces' inode numbers. They also check the limits' argument
validation, that the sleeper subshell reaps what it starts, and the
order in which the main body runs the profile, limit, and share checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the agents/fix-e2e-testing-nvxpy-sandbox branch from bc8ba45 to d084db6 Compare October 8, 2026 14:36
Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:36

Copilot AI left a comment

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.

🟡 Changes recommended

The new process-scan test leaks its spawned sleep process during cleanup.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread scripts/test_microvm_tests.py
Part of #229.

managed-exec-config runs provision, start, exec, stop, and deprovision
through scripts/nvx.py, but it checks request-scoped execution options.
Nothing exercised the lifecycle's own contract through the public
interface: that a warm guest keeps its state across requests and
restarts, that every invalid transition fails explicitly without leaking
an OpenVMM process, control endpoint, capability, or runtime record, and
that stop and deprovision remove only NVX's state while the supplied
artifacts survive. The managed-lifecycle scenario covers part of this
through OpenVMM's CLI, which bypasses NVX's lifecycle code.

Scenario
--------

The explicit sandbox-lifecycle scenario of test-microvm runs nvx.py
sandbox as subprocesses against the real kernel, Alpine control
initramfs, Ubuntu EROFS layer, and fresh copies of the Ubuntu scratch
template. It checks, in order, that:

- provision rejects a root --workload-user before it creates any state,
  and the guest refuses an identity that the Ubuntu image lacks, both
  before a one-shot workload starts and in a managed start, which then
  fails without leaving a runtime record, capability, socket, or
  OpenVMM process;
- start, exec, stop, and deprovision fail on a state directory that was
  never provisioned and do not create it, a repeated provision keeps the
  configuration, and exec and stop fail before start;
- a repeated start and a deprovision of a running sandbox fail without
  changing its runtime record, capability, or OpenVMM process, and the
  sandbox keeps serving requests;
- arguments with leading, trailing, and embedded spaces, tabs, and
  newlines reach the workload intact, and a later request reads the file
  that an earlier one wrote;
- nvx-sandbox-smoke passes its profile and `limits` checks in a sandbox
  provisioned with --hostname, --memory-max 33554432, and --pids-max 8,
  after the earlier requests;
- a request that writes 2 MiB gets status 125, an output-limit report
  that holds only the operation, category, and status, and at most 1 MiB
  of output but more than 1 MiB less the agent's 32 KiB read chunk,
  since the agent drops the read that would exceed the bound; and a
  request whose --outcome-report path exists fails before its workload
  runs and leaves the file intact;
- stop ends OpenVMM and its socket or pipe, leaves only config.json,
  openvmm.log, and a successful outcome.json, and leaves a cleanly
  unmounted scratch file system, after which exec and stop fail;
- after a restart, the file written before stop is still there;
- once OpenVMM is killed, exec and stop report stale runtime state and
  start refuses it; and
- deprovision fails and keeps a foreign file in the state directory, and
  once that file is gone, it removes the directory, which a later exec
  does not recreate.

After each step, exactly the OpenVMM processes that should run for the
fixture do, found by their arguments in /proc on Linux and in
Win32_Process on Windows. The EROFS layer's SHA-256 never changes, and
the scratch image survives deprovision.

On failure, the scenario stops what it started, kills every OpenVMM
process that names its fixture, deprovisions both sandboxes, and
preserves the fixture only when that cleanup fails. Its evidence holds
bounded command observations (arguments, statuses, output sizes, and
state-directory entries), the runtime record, OpenVMM logs, the
VM-level and exec outcome reports, the probe transcript, and the refused
one-shot run's console, each limited to its last 64 KiB. The command
observations hold no workload output, and no evidence holds the control
capability.

Supporting changes
------------------

sandbox_lifecycle.runtime_process_running() exposes the process identity
check that start, exec, stop, and deprovision already use, so the
scenario applies the same definition of a running OpenVMM.
managed_exec_tests shares its artifact loading and bounded-diagnostic
helpers with the new scenario instead of duplicating them. Neither
public scenario runs in the default suite, because packages omit the
scratch template; the --scenario help and usage.md now say so for
managed-exec-config as well.

CI and docs
-----------

The public managed sandbox acceptance step on KVM, MSHV, and WHP runs
sandbox-lifecycle after managed-exec-config, and its evidence lands in
the public-exec results that the job uploads on failure.

run.md describes the scenario and the managed exec output bound, and
ci.md, usage.md, validation.md, contribute.md, and the adversarial
testing design describe the new coverage.

Tests
-----

Unit tests drive the scenario against a fake CLI that models the state
directory, the guest's files, and OpenVMM processes. The complete run
issues the expected sequence of operations and writes bounded evidence
without the capability, and each of twelve injected regressions fails it
with a specific message while cleanup still ends every process and
removes the fixture. A failed cleanup preserves the fixture, and a noisy
OpenVMM log leaves only its bounded end in the evidence. Other tests
cover the scratch journal check, killing a recorded process, the control
endpoint check for sockets and named pipes, the Linux process scan, the
dispatcher, the default suite's exclusion of both public scenarios,
runtime_process_running(), and the CI wiring.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Part of #229.

The validate-nvx action names each scripts/test_*.py module explicitly
in its Linux and Windows unittest invocations. A new module that is
left out never runs in CI, as scripts/test_control_session.py once ran
in neither job.

A unit test now requires that the modules each step names equal the
modules in scripts/, so adding a module without adding it to both
steps, or removing one, fails the CLI validation itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the agents/fix-e2e-testing-nvxpy-sandbox branch from d084db6 to 7d572fb Compare October 8, 2026 14:50
Copilot AI balanced review requested due to automatic review settings October 8, 2026 14:50

Copilot AI left a comment

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.

🔵 Needs a closer look

Cross-platform hypervisor lifecycle and security behavior requires final human review despite extensive automated coverage.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

@ppenna
Pedro Henrique Penna (ppenna) merged commit d3eb15f into dev Oct 8, 2026
31 checks passed
@ppenna
Pedro Henrique Penna (ppenna) deleted the agents/fix-e2e-testing-nvxpy-sandbox branch October 8, 2026 15:44
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.

Close end-to-end testing gaps for nvx.py sandbox

2 participants