Voidseal is a defensive containment tool. Its design goal is to supervise capability, not behavior — assume the code or agent inside the sandbox is a prompt-injectable insider, and make the blast radius structurally small.
- Untrusted code / autonomous agents / (eventually) malware running with the host's network, credentials, or filesystem in reach.
- A guest that misbehaves, is compromised, or lies about its own state.
The core control is the host-verified, fail-closed seal gate (Assert-Sealed): after isolation is
cut, the host (never the guest's self-report) certifies the VM against its tier's contract, and the
workload is structurally unreachable unless that certification returns true. What gets certified is
tier-scoped, and the scope matters. At every tier the host verifies that no import DVD or ISO is still
attached, that no secret-shaped or unrecorded volume remains, that the host channels it can read report
off (including the Time Synchronization, Key-Value Pair Exchange, Backup and Heartbeat integration
services), that COM2 carries no pipe path, that no checkpoint exists and automatic checkpoints are off,
and that no passthrough device is assigned: no GPU partition adapter and no device handed over by
Discrete Device Assignment, which could be a physical NIC that the virtual-adapter read never sees.
Every VM except a Tier-1 non-processor must seal with zero network adapters. One predicate,
Get-SealNicPolicy in scripts/lib/SealPolicy.ps1, makes that call for the Provisioner, Lock-Sandbox
and Assert-Sealed alike: Tier 0, Tier >= 2, and any profile that declares Network='None' (a
"processor") at any tier get no adapter. Real New-VM always creates one, so the seal removes it and the
gate requires the host to report none; a descriptor that records no Network, or a policy value the
predicate does not recognize, is refused outright. The one permissive case is an exact allow-list
entry: a Tier-1 VM keeps a NIC only when its Network label is exactly Internal+Allowlist, the value
tier-profiles/tier1.psd1 ships. Any other Tier-1 label stops New-SandboxVM before it creates
anything and is refused by the gate. For a
Tier-1 VM, which legitimately keeps a NIC
(net-restricted, not no-net), the seal instead certifies that the VM has exactly one NIC and that it
sits on the VM's own recorded switch: the <vm>-int Internal switch New-SandboxVM built and
recorded as the descriptor's SwitchName, which must still read as SwitchType=Internal and never as an
unfiltered External or Private switch. The guest's only possible route off-box is therefore the
host-controlled gateway on that one switch. Since 2026-10-03 a NIC re-pointed at any other Internal
switch (another sandbox's, or one the host routes or NATs), a second NIC, and a descriptor with no
recorded SwitchName are all refused; before then any Internal switch not named "Default Switch"
certified. That is a host-verified switch-isolation
guarantee (reachability) for a purpose-built Internal switch, not a claim about which domains/IPs the
guest can reach once on that route — see "Tier-1 egress" below for why the two are not the same thing.
Caveat: the built-in "Default Switch" is itself SwitchType=Internal (it is Hyper-V's ICS/internet-
connected switch, not a purpose-built isolated one), so the SwitchType check alone cannot distinguish it
from a purpose-built isolated switch — but the seal now ALSO refuses it explicitly by name, so a
Default-Switch-connected NIC is refused, not certified. Real Tier-1 provisioning uses a purpose-built
Internal switch, never the Default Switch. This by-name match is the switch's ENGLISH friendly name
("Default Switch"); on a non-English/localized Windows host the friendly name differs, so the by-name
refusal does not fire there — the locale-independent guarantee is the immutable-GUID refusal
(c08cb7b8-9b3c-408e-8e30-5e16a3aeb444), which is Phase-6-live, not yet shipped. Switch isolation and the
by-name refusal are reachability controls. Egress filtering for a Tier-1 VM is a separate layer:
since 2026-10-03 a Tier-1 profile runs with EgressMode='HostProxy', where the host enforces an allowlist
and the seal gate verifies the host objects that enforce it. That layer is mock-green and has not run on a
live host yet (see "Tier-1 egress" below). Higher tiers
additionally starve the guest of network and credentials at load time (refused if a Tier ≥ 2 profile
declares any), and route hostile output through a one-way cold-disk quarantine boundary rather than a
trusting host read.
The processor profile tier (Network='None' AND a ScreenConfig) is the one place Voidseal moves
guest-produced content back to the host: an in-guest screener (guest/screener.py) evaluates each
artifact and a host-side gate (Invoke-SensitivityGate, scripts/lib/SensitivityGate.ps1) partitions
the result into released/ (host-approved) and held/ (everything else). Firefox (Tier-0, transport-
only) is a separate, unscreened path — see the note at the end of this section.
The screener runs inside the guest, in the ScreenConfig mode the seed passes to the in-guest
producer, so its verdicts come from the same workload whose output they judge. A compromised workload
can mark anything SAFE. The guarantee below therefore rests on what the host regenerates and caps,
not on the screener's verdicts.
What this control is not. An earlier design implicitly framed the screener as an information-level data diode — as if a passing "SAFE" verdict meant the released bytes were provably free of adversary- chosen content. That framing over-claims. An offline content screen cannot give an information-theoretic bound against a producer that fully controls what it emits: this is the classical Lampson/TCSEC covert- channel result, and it is sharpened by modern provably-secure generative linguistic steganography, which shows that ordinary-looking "clean" prose can carry attacker-chosen bits with no statistical tell a detector could catch. No amount of tuning the screener's detectors closes that gap, because detection accuracy on benign inputs says nothing about a worst-case adversarial input.
What this control actually is. Voidseal does not rely on detection to bound leakage — it relies on regeneration. The host is the sole author of everything on the released surface:
- The screener emits an enum-only verdict with zero free-form fields:
verdict∈{SAFE, HELD, ERROR}(3),error_code∈{NONE, EXTRACT_FAIL, OFF_SCHEMA, DETECTOR_ERROR, UNSUPPORTED}(5), andflags— a subset of a fixed 10-tag detector vocabulary (aws_key,credential,email,entropy,financial,health,other,pii,secret,ssn) — plusnameandsha256.sha256is independently re-derived and re-checked by the host rather than trusted (the host re-hashes the staged bytes and compares; a producer-claimed hash that doesn't match the real bytes never releases).nameis validated against a fixed 40-character[A-Za-z0-9._-]charset (a violating name is rejected as off-schema —held/, never released) — but even a charset-confined guest name is never itself released: see the name-regeneration point below, which is what keepsnameoff the published bound entirely. - The host validates every verdict against that exact closed schema before trusting it — an unknown
key, an out-of-enum value, an off-vocabulary flag, an array-typed field where a scalar is expected,
a name outside the 40-char restricted alphabet, or a malformed hash is rejected outright (routed to
held/, never released), never coerced or partially accepted. - The host re-hashes each candidate file immediately before release and releases only if the current
bytes match the verdict's own
sha256— this closes the window between when the file was screened and when it is copied out. - The released report is regenerated from scratch by the host from the validated fields — the
guest's
verdicts.jsonis never copied into the released report verbatim, and the released entries themselves carry onlyname+ a host-recomputedsha256(theverdict/error_code/flagsenum fields stay on theheld/-side audit trail; they never ride on the released surface at all). Therun_idstamped on the report is a fresh host-generated GUID, never a producer-supplied value. - The released filename is host-regenerated, not the producer's name (
scripts/lib/SensitivityGate.ps1,Get-ReleasedName). Even confined to the 40-char restricted alphabet, a producer-chosen name is still a real channel — the same regeneration argument that applies to file content applies to the name, since detection/charset-capping cannot bound a worst-case adversarial string any more than a screener can bound worst-case adversarial bytes. Soreleased[].name(and the in-memory.Releasedreturned toInvoke-Voidseal's caller) is always the host-recomputedsha256of the released bytes — a value the producer does not choose and cannot influence — never the guest's original filename. The original guest-chosen name is preserved only in the regenerated report'sreleased_auditarray ({released_name, original_name, sha256}, written tomanifest/sensitivity-report.jsonalongside the retained verbatimmanifest/verdicts.jsonaudit copy) so an operator can still recover which released file is which — but that mapping never rides onreleased/or.Released. This closes the name channel to zero producer-influenced bits on the released surface.
Because every field that reaches the released surface is either drawn from a small closed enum the host
validates, or independently recomputed by the host (both the byte-content sha256 and, as of the
host-name-regeneration hardening above, the filename itself), the worst-case channel capacity is
computable directly from the schema, not from how well the detectors happen to work on benign input.
That is the honest guarantee: policy-enforced release with a quantified covert-channel bandwidth — a
one-way, TCB-controlled declassification channel with a bounded, published worst-case capacity, not an
absolute information barrier.
The published bound. Summing log2(|enum_i|) over the verdict schema's enum fields (Σ over
verdict{3}, error_code{5}, and flags as a 10-bit subset-selector over the fixed 10-tag vocabulary,
since flags is a set — any of 2¹⁰ combinations — not a single enum draw):
| Field | Domain | Bits (log2) |
|---|---|---|
verdict |
3 values | log2(3) ≈ 1.58 |
error_code |
5 values | log2(5) ≈ 2.32 |
flags |
subset of 10 tags (2¹⁰ combinations) | log2(2¹⁰) = 10.00 |
| Σ per screened artifact | ≈ 13.9 bits |
name is not in this table: an earlier draft of this document counted a ≈240.9-bit worst-case
channel for name (40 chars × log2(65), the charset-capped-but-still-producer-chosen name riding
verbatim onto released[].name) — that was the correct bound for the design at the time, but it is no
longer the design. The released filename is now host-regenerated (see the name-regeneration point above):
the producer's name never reaches released[].name in any form, charset-capped or otherwise, so it
contributes exactly 0 bits to the released surface today. verdict/error_code/flags are
genuinely held-side audit fields that never ride on released/ at all (see the regenerator design above)
— their ≈13.9 bits are a ceiling for a hypothetical future schema change that put them on the released
surface, not bits an attacker can use today; the honest current bound on the released surface's
enumerable fields is 0 bits (the name field is host-derived; no other free field rides on
released[]). The remaining producer-influenced degrees of freedom that reach released/ are: (1)
which of the staged files the host accepted as SAFE (one bit per staged file, bounded by the
file-count backstop below), and (2) each released file's own byte content (bounded by the byte-budget
backstop below) — filename is no longer one of them. The enum sizes are directly checkable against
guest/screener.py (VERDICT_ENUM, ERROR_CODE_ENUM, FLAG_VOCAB) and their pinned mirror in
scripts/lib/SensitivityGate.ps1 ($script:VerdictSchema, including NameRE, which still gates what
name the host will even trust enough to hash-bind and audit-map, even though that name is never
released verbatim). Both are covered by a drift-pinning test (tests/guest/test_screener.py,
test_module_enum_constants_match_the_pinned_vocabulary_copies) so this number cannot silently go stale:
any change to the enum sizes must update this table in the same change.
Two aggregate backstops, independent of the per-run bound. The per-run figure above bounds a single
processor invocation; aggregate exposure over many runs is bounded separately by two host-enforced,
fail-closed caps (scripts/lib/SensitivityGate.ps1, scripts/lib/ReleaseGovernor.ps1):
- Released-byte budget — a per-artifact size cap (
-MaxReleasedBytes, default 1 MiB) and a per-run releasable-file-count cap (-MaxReleasedFiles, default 16). Anything over either cap is held, not released, regardless of how clean its verdict is. - Runs/day rate cap — a host-side ledger (
-MaxReleasesPerDay, default 5) keyed by profile and UTC calendar day. A run at or over the cap releases nothing at all — the whole candidate set is held before the per-file schema/re-hash checks even run. An unreadable/corrupt ledger fails the same way (denied), never fails open.
Together these mean total exfiltratable content across a day is bounded by runs/day x min(byte-budget-bound, per-run-schema-bound) — a number that shrinks as the caps tighten, never grows.
Scope note. This entire section describes the processor release path only. The firefox
profile (Tier-0) is transport-only and is not screened by this mechanism at all: its extracted artifact
is materialized host-side verbatim from a raw, user-space disk read via the shared in-guest outbox
producer (never a kernel filesystem mount of guest-written bytes — see docs/operator-runbook.md for the
outbox transport detail), with no sensitivity gate in the path. Firefox's containment guarantee is the
seal + disk-passing mechanism above, not the regenerator described in this section.
The user-space outbox read (ReadVhdxRawRegion, scripts/lib/HyperVBackend.ps1) never Mount-VHDs the
guest-written OUTPUT disk (even a read-only host attach runs partmgr.sys + filesystem-recognizer
parses against attacker-controlled bytes). Instead it runs qemu-img convert -f vhdx -O raw offline against
the detached .vhdx, then reads the flattened raw bytes with a plain FileStream. This closes the
kernel-filesystem-parse risk, but it opens a different one: qemu-img's own VHDX parser now runs against
those same untrusted bytes, in the host operator's own session.
Be precise about what this risk is. The only catalogued QEMU VHDX-parser CVE is CVE-2014-0148
(a heap-overflow denial-of-service in the VHDX image-parsing code, fixed at QEMU 2.0) — over a
decade old and far below any reasonable modern floor. Framing the current risk as "the qemu-img convert is
vulnerable to a known RCE" would be a fabricated claim: no such catalogued RCE exists for the modern
VHDX parser. The real concern is the undiscovered-bug class: any offline file-format parser handling
adversary-controlled input can contain an unpatched memory-safety bug, and qemu-img convert runs with the
full privileges of whoever invokes it — here, the host operator.
Mitigations, in place today or planned:
- A patch-currency version floor (
Resolve-QemuImg,-MinVersion) — not a CVE-derived gate (see that function's own header comment, which is explicit on this point). The floor exists so the resolver runs a currently patchedqemu-img, re-resolved and bumped periodically; it is not a claim that a specific CVE is fixed at exactly that version. - A SHA-256 pin of the qemu-img binary (
Resolve-QemuImg -PinnedSha256, optional) — refuses to run an unpinned or substituted parser binary when a pin is configured. - Per-tier confinement of the convert itself (
Invoke-ConfinedQemu,scripts/lib/HyperVBackend.ps1) — every nativeqemu-img convertinvocation is routed through a single confinement seam rather than called directly. v1 (shipped) is a pass-through — the seam exists and every call site is routed through it (AST-pinned by test, non-vacuously), but the actual confinement mechanism is not yet wired: a restricted-token/Job-Object shim for Tier-0/1 (drop privileges + cap resources on the childqemu-imgprocess) or a Windows Sandbox for Tier-2/3 (full OS-level confinement for the disposable/detonation tiers). The real confinement mechanism will be built and live-proven in Phase 6, which has not run yet (docs/phase-6-live-runbook.mdis marked NOT YET LIVE); until then, treat the convert step as running with full host-operator privileges, mitigated only by the version floor and binary pin above.
- The Tier-0/1 engine is mock-proven (1652 Pester tests passed, 0 failed, 2 skipped, plus 194 pytest tests,
all against the fake backend). The Tier-0
firefoxdisk round-trip has run end-to-end on real Hyper-V: with the legacy exFAT OUTPUT disk on 2026-06-25, and with the current user-space outbox transport on 2026-07-21. Changes made after that run are mock-tested only. No other tier has had a live acceptance run; Tier 1 remains the operator's elevated step. - The zero-adapter seal and the passthrough refusal are mock-proven only. Since 2026-10-03 every
Tier-0 seal runs the real
RemoveNetworkAdapter, and every gate readsGet-VMGpuPartitionAdapterandGet-VMAssignableDevice. None of the three has run on a live host; the next Tier-0firefoxre-run is their first live exercise. A failure there refuses the seal and tears down, so the failure mode is a refused run, not an unsealed one. - Tier 2/3 (disposable no-net / air-gapped detonation) and the cold-VHDX→quarantine extraction are
scaffold-only in v1 (the quarantine sink throws
NotImplemented). No live malware or untrusted-plugin detonation should be run until verified isolation is in place. - Tier-1 egress: host-enforced under HostProxy, the one model for a VM that keeps its NIC.
With
EgressMode='HostProxy'(whattier-profiles/tier1.psd1ships since 2026-10-03, so ralph and the builder use it) the guest has one static address in a /30 of its own switch and no route, NAT or resolver. The host holds the gateway address, runsscripts/egress/voidseal_egress_proxy.pyon it, and closes everything else twice: host firewall rules on that interface and a port ACL on the VM's NIC both admit only TCP from the guest's address to the proxy port. The proxy tunnels onlyCONNECT <name>:443for names on the run's mergedEgressAllowlist, resolves each name itself, and refuses an answer that contains a private, loopback, link-local, multicast, reserved or otherwise non-global address, so the guest picks a name and the host picks the address. Nothing in the guest takes part in the decision. The in-guest default-DROP the seed installs (Serial and Disk seeds alike, the builder's included) is defense in depth: a guest with code execution can remove it, so treat it as raising the bar for a non-adversarial workload, not as the boundary. Since 2026-10-16 there is no other egress model: a kept NIC under any otherEgressModeis refused at load, at the seal and at the gate, and the builder's former in-guest proxy is gone. - A domain allowlist is only as narrow as the names on it. The HostProxy allowlist admits names, not
parties: an allowed name that fronts a shared CDN or a public storage service (ralph's
storage.googleapis.comadmits any public bucket) can carry traffic to or from someone other than the intended service, and the proxy cannot see inside the TLS tunnel. Keep allowlists narrow; seedocs/threat-model.md. - The HostProxy host objects are mock-proven only. The gateway address, the seven firewall rules, the
three port ACL entries, the NAT read and the proxy process start have run only against stub cmdlets and
the fake backend (
tests/fixtures/Invoke-RealEgressCapture.ps1,tests/HostEgress.Tests.ps1). Every read fails closed, so a live surprise is a refused seal or a failed provision, not an open path. The checks the first elevated run must make are indocs/phase-6-live-runbook.mdsection 2. - Three of the eight host channels are reported off by construction, not measured.
Assert-Sealediterates all eight (clipboard, shares, guest-services, enhanced-session, and since 2026-10-03 the Time Synchronization, Key-Value Pair Exchange, Backup and Heartbeat integration services), but the real backend'sGetHostChannels(scripts/lib/HyperVBackend.ps1) returns a hardcoded$falsefor clipboard, shares and enhanced-session. It readsGuestServicesfrom the host by its English display name, and the four integration services by the component GUID at the end of each service'sId, so their read does not depend on the host's language. That read is genuinely fail-closed: it rethrows rather than coercing an unreadable channel to off. The other three rest on an argument, documented in that function, that Hyper-V exposes no per-VM Enhanced Session signal and that the ESM facets are structurally absent on a stock Linux guest. Treat the host-channel gate as five host-verified channels plus three asserted-absent ones. The fake backend models all eight as readable, so for those three the mock suite exercises a check the real host cannot perform. - A pre-built disk is attached as a per-run working copy, read-write; read-only is the guest's mount.
For
DataDisksand the processor's DEPS disk, each run copies the original into its own sandbox folder, verifies the COPY's whole-image hash, and attaches the copy; teardown deletes it. Hyper-V cannot attach a VHDX read-only and the in-guest user has passwordless sudo, so a hostile guest can write to its copy, but that write is discarded and never reaches the original or a later run. After teardown each data disk's original is re-hashed, which reports a change made on the host during the run (DataDiskChanged, plus a warning) without failing the run. Each copy costs disk space and copy time on every run. The real copy, attach and re-hash are mock-tested only (no live run). - Shutdown stays enabled, and host time still reaches the guest. The seal leaves the Shutdown
integration service on because the engine's
StopVMcalls passForcewithoutTurnOff, which asks the guest to shut down through it; it carries a host-to-guest request only. Turning off Time Synchronization narrows the guest's view of host time but does not remove it: the Hyper-V reference TSC page stays readable and host-paced, and no per-VM setting is known to close it. - The integration-service, COM2 and checkpoint checks are mock-proven only. Since 2026-10-03 the
gate also refuses a path on COM2, automatic checkpoints, and any existing checkpoint. The component
GUIDs,
Get-VMComPort -Number 2,Get-VM'sAutomaticCheckpointsEnabledandGet-VMSnapshothave run only against stub cmdlets; every one fails closed, so a live surprise is a refused seal. - The secret-path denylist is a lint, not a control.
Test-IsSecretPath(scripts/lib/ProfileLoader.ps1) matches filename globs and directory segments against theMountsandStageAssetssource keys,DataDiskspaths, and every path in a treeNew-VoidsealDataDisk.ps1builds a disk from. It never opens or inspects a file, so it cannot tell a real credential from a file that merely looks like one, and it does not fire on a credential the caller renamed. It reduces accidents. It is not a cryptographic or content-based boundary.- What is host-verified: switch isolation for every Tier-1 VM, and the egress objects under HostProxy.
Assert-Sealedverifies (from the host, never the guest's self-report) that a Tier-1 VM has exactly one NIC and that it sits on the VM's own recorded Internal switch (the descriptor'sSwitchName), not on an External or Private switch and not on any other Internal switch. That is a genuine host-enforced guarantee that the guest's only possible network path leaves through the host-controlled gateway on that Internal switch — an unfiltered External/Private switch is refused, fail-closed. It says nothing about which destinations are reachable through that gateway; for a HostProxy VM that is the next bullet's job. Caveat: the built-in "Default Switch" is itselfSwitchType=Internal(Hyper-V's ICS/internet- connected switch), so the SwitchType check alone cannot distinguish it from a purpose-built isolated Internal switch — but the seal now ALSO refuses it explicitly by name (host-verified, mock-tested), so a Default-Switch-connected NIC is refused, not certified. Real Tier-1 provisioning uses a purpose- built Internal switch, never the Default Switch. This by-name match is the switch's ENGLISH friendly name; on a non-English/localized Windows host the friendly name differs, so the by-name refusal does not fire there — the locale-independent guarantee is the immutable-GUID refusal (c08cb7b8-9b3c-408e-8e30-5e16a3aeb444), which is Phase-6-live, not yet shipped. This is still a reachability/isolation control, not egress filtering; see the next bullet. - Egress filtering at the host (HostProxy) is built and gate-verified, mock-green only. For a VM that
records
EgressMode='HostProxy',Lock-Sandboxinstalls the gateway address onvEthernet (<vm>-int), the host firewall rules scoped to that interface and the port ACL, recording each one, andAssert-Sealedrefuses unless every one is on the host exactly as recorded, the record is the full set the VM's address plan requires, no other host address overlaps the VM's /30, and no NAT network covers it. Any read that fails refuses. The proxy starts only after the gate certifies and before the guest boots, and teardown removes exactly what was recorded. There is no NAT at all in this design, so the one-NAT-per-host contention with Docker and WSL does not arise. What remains unproven is live behavior (rule matching on a real interface, ACL evaluation at the vSwitch port, the proxy under a real client); until the first elevated run, read "host-enforced" as "host-enforced by design and in the mock suite." A kept NIC has no other egress model: the loader, the seal and the gate each refuse one. Seedocs/phase-6-live-runbook.mdfor the live sequence and checks.
- What is host-verified: switch isolation for every Tier-1 VM, and the egress objects under HostProxy.
Do not rely on Voidseal as your sole boundary for genuinely hostile code until the higher tiers are completed and you have independently verified the isolation on your host.
Voidseal's security claim is narrow: the host-verified, fail-closed Assert-Sealed gate (described
above) plus the tier isolation it verifies. What a workload does once sealed and running — whether its
own logic is correct, whether an agent loop it drives goes off the rails — is the calling profile's own
model, not something Voidseal guarantees; Voidseal supervises capability, never behavior, the same
component-vs-product line bubblewrap draws for the low-level primitives it wraps rather than claiming to be
a complete sandboxing product on its own.
docs/threat-model.md is the full write-up: a gVisor-style
attacker-prerequisite ladder (Remote → GuestUser → GuestRoot → MaliciousProfile → HostOperatorMisconfig →
HostRoot) crossed with a per-tier table that classifies each threat class as IN SCOPE (a bug here is a
Voidseal defect, traced to the exact gate in Sealer.ps1 / ProfileLoader.ps1), HOST MISCONFIG / OUT OF
SCOPE, or KNOWN LIMITATION, TRACKED — the Tier-1 egress gap worked through end-to-end as the
centerpiece example — plus a maturity section (Tier 2/3 scaffold-only, no shipped tier file, no live run
yet) kept deliberately separate from the scope question.
Please open a GitHub issue for non-sensitive reports. For anything that could weaken containment in a way that shouldn't be public yet, contact the maintainer privately via the email on the GitHub profile rather than filing a public issue.
Please don't submit an AI-generated vulnerability report you haven't personally verified. A plausible-sounding but unconfirmed model-generated claim about a security gap costs real maintainer time to triage and rule out — reproduce the issue yourself first, or clearly label the report as unverified and explain why you're surfacing it anyway.