Skip to content

Security: Polycentric-Labs/voidseal

SECURITY.md

Security

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.

Threat model (what Voidseal defends against)

  • 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.

Release path (processor tier): policy-enforced release, not an information barrier

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), and flags — a subset of a fixed 10-tag detector vocabulary (aws_key, credential, email, entropy, financial, health, other, pii, secret, ssn) — plus name and sha256. sha256 is 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). name is 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 keeps name off 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.json is never copied into the released report verbatim, and the released entries themselves carry only name + a host-recomputed sha256 (the verdict/error_code/flags enum fields stay on the held/-side audit trail; they never ride on the released surface at all). The run_id stamped 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. So released[].name (and the in-memory .Released returned to Invoke-Voidseal's caller) is always the host-recomputed sha256 of 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's released_audit array ({released_name, original_name, sha256}, written to manifest/sensitivity-report.json alongside the retained verbatim manifest/verdicts.json audit copy) so an operator can still recover which released file is which — but that mapping never rides on released/ 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 qemu-img raw read: an undiscovered-bug-class risk, not a catalogued RCE

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 patched qemu-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 native qemu-img convert invocation 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 child qemu-img process) 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.md is 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.

Status / honesty

  • 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 firefox disk 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 reads Get-VMGpuPartitionAdapter and Get-VMAssignableDevice. None of the three has run on a live host; the next Tier-0 firefox re-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' (what tier-profiles/tier1.psd1 ships 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, runs scripts/egress/voidseal_egress_proxy.py on 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 only CONNECT <name>:443 for names on the run's merged EgressAllowlist, 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 other EgressMode is 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.com admits 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; see docs/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 in docs/phase-6-live-runbook.md section 2.
  • Three of the eight host channels are reported off by construction, not measured. Assert-Sealed iterates 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's GetHostChannels (scripts/lib/HyperVBackend.ps1) returns a hardcoded $false for clipboard, shares and enhanced-session. It reads GuestServices from the host by its English display name, and the four integration services by the component GUID at the end of each service's Id, 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 DataDisks and 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 StopVM calls pass Force without TurnOff, 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's AutomaticCheckpointsEnabled and Get-VMSnapshot have 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 the Mounts and StageAssets source keys, DataDisks paths, and every path in a tree New-VoidsealDataDisk.ps1 builds 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-Sealed verifies (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's SwitchName), 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 itself SwitchType=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-Sandbox installs the gateway address on vEthernet (<vm>-int), the host firewall rules scoped to that interface and the port ACL, recording each one, and Assert-Sealed refuses 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. See docs/phase-6-live-runbook.md for the live sequence and checks.

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.

Scope: what's a Voidseal bug vs. not

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.

Reporting a vulnerability

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.

There aren't any published security advisories