From c3bd6264e4eebba58319c1d9dcfe3daedcfa9352 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Thu, 4 Jun 2026 15:54:38 -0400 Subject: [PATCH 01/12] feat(dns): host DNS proxy + patched libslirp for macOS VPN/split-DNS On macOS, libslirp forwards guest DNS to a single libresolv-picked resolver and ignores the system's scoped/VPN/split-DNS resolvers, so guest DNS fails (often entirely) under a VPN. This bundles a patched libslirp that redirects guest DNS to $QUICKSAND_DNS_PROXY, plus a host-side proxy that resolves via the OS resolver (getaddrinfo), which honors scoped/VPN/split-DNS. Auto-enabled on macOS hosts with network_mode=FULL. - quicksand-core: HostDnsProxy module, lifecycle wiring, host_dns_proxy config, dnslib (macOS-only) dependency, unit tests. - quicksand-qemu: build hook compiles patched libslirp 4.9.3 (patches/) into the bundled dylib, with a SONAME guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/quicksand-core/pyproject.toml | 8 +- .../quicksand-core/quicksand_core/_types.py | 11 ++ .../quicksand_core/host/dns_proxy.py | 117 ++++++++++++++++ .../quicksand_core/sandbox/_lifecycle.py | 42 ++++++ .../quicksand_core/sandbox/_protocol.py | 2 + .../quicksand_core/sandbox/_sandbox.py | 1 + packages/quicksand-qemu/hatch_build.py | 131 ++++++++++++++++++ .../patches/libslirp-dns-proxy.patch | 57 ++++++++ tests/unit/test_dns_proxy.py | 89 ++++++++++++ uv.lock | 11 ++ 10 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 packages/quicksand-core/quicksand_core/host/dns_proxy.py create mode 100644 packages/quicksand-qemu/patches/libslirp-dns-proxy.patch create mode 100644 tests/unit/test_dns_proxy.py diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index f894f2d..6bd5b19 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -5,7 +5,13 @@ description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" license = "MIT" -dependencies = ["httpx>=0.27,<1.0", "pydantic>=2.0", "quicksand-smb>=0.4.9,<0.5.0"] +dependencies = [ + "httpx>=0.27,<1.0", + "pydantic>=2.0", + "quicksand-smb>=0.4.9,<0.5.0", + # Host-side DNS proxy for macOS VPN/split-DNS resolution (see host/dns_proxy.py). + "dnslib>=0.9.24; sys_platform == 'darwin'", +] [build-system] requires = ["hatchling"] diff --git a/packages/quicksand-core/quicksand_core/_types.py b/packages/quicksand-core/quicksand_core/_types.py index d609538..4082231 100644 --- a/packages/quicksand-core/quicksand_core/_types.py +++ b/packages/quicksand-core/quicksand_core/_types.py @@ -367,6 +367,11 @@ class EnvironmentVariables: QEMU_MODULE_DIR = "QEMU_MODULE_DIR" + # Read by the bundled (patched) libslirp on macOS: when set to a port + # number, guest DNS forwarded to 10.0.2.3 is redirected to 127.0.0.1: + # where the host DNS proxy answers via the OS resolver. See host/dns_proxy.py. + DNS_PROXY = "QUICKSAND_DNS_PROXY" + # ============================================================================= # Guest Commands @@ -587,6 +592,11 @@ class SandboxConfig(BaseModel): accel: Accelerator | Literal["auto"] | None = "auto" disk_size: str | None = None enable_display: bool = False + host_dns_proxy: bool | None = None + """Route guest DNS through a host-side proxy that resolves via the OS + resolver. Fixes macOS VPN/split-DNS failures (see host/dns_proxy.py). + ``None`` (default) auto-enables on macOS hosts with ``network_mode=FULL``; + ``True``/``False`` force it on/off. No effect on Linux/Windows hosts.""" @field_validator("memory") @classmethod @@ -623,6 +633,7 @@ class SandboxConfigParams(TypedDict, total=False): accel: Accelerator | Literal["auto"] | None disk_size: str | None enable_display: bool + host_dns_proxy: bool | None _td_keys = set(SandboxConfigParams.__annotations__) | {"image"} diff --git a/packages/quicksand-core/quicksand_core/host/dns_proxy.py b/packages/quicksand-core/quicksand_core/host/dns_proxy.py new file mode 100644 index 0000000..b9b247e --- /dev/null +++ b/packages/quicksand-core/quicksand_core/host/dns_proxy.py @@ -0,0 +1,117 @@ +"""Host-side DNS proxy for macOS VPN/split-DNS resolution. + +On macOS, libslirp discovers a single upstream nameserver via libresolv and +forwards all guest DNS to it from an unbound socket. It does not honor the +system's scoped resolvers (``scutil --dns``), so when a VPN is active the +guest's DNS fails — often entirely — because the chosen resolver is reachable +only through the VPN interface, or the wrong scope is picked. Toggling the VPN +just reshuffles which resolver gets picked, which is the intermittent symptom +users hit. + +The bundled libslirp is patched so that, when ``$QUICKSAND_DNS_PROXY`` names a +port, it redirects the DNS it forwards (guest -> 10.0.2.3) to +``127.0.0.1:``. This module is the host-side endpoint of that redirect: a +tiny DNS server that answers A/AAAA via :func:`socket.getaddrinfo`, the same OS +resolver every working macOS app uses — so it honors scoped/VPN/split-DNS +automatically. + +The guest's slirp network is IPv4-only, so AAAA answers are intentionally left +empty (macOS returns v4-mapped addresses for AAAA, which are not real IPv6); +clients fall back to A. +""" + +from __future__ import annotations + +import logging +import socket +from typing import TYPE_CHECKING + +from ..utils import find_free_port + +if TYPE_CHECKING: + from dnslib import DNSRecord + from dnslib.server import DNSHandler + +logger = logging.getLogger("quicksand.dns_proxy") + + +class _GetAddrInfoResolver: + """dnslib resolver that answers A/AAAA via the host's system resolver.""" + + def resolve(self, request: DNSRecord, handler: DNSHandler) -> DNSRecord: + from dnslib import AAAA, QTYPE, RR, A + + reply = request.reply() + qtype = QTYPE[request.q.qtype] + name = str(request.q.qname).rstrip(".") + + # Only A/AAAA are answered via getaddrinfo. Other types get an empty + # NOERROR so the guest's resolver falls back cleanly instead of hanging. + if qtype not in ("A", "AAAA"): + return reply + + family = socket.AF_INET if qtype == "A" else socket.AF_INET6 + try: + infos = socket.getaddrinfo(name, None, family, socket.SOCK_STREAM) + except socket.gaierror: + return reply # name does not resolve — empty answer + + seen: set[str] = set() + for info in infos: + fam = info[0] + ip = str(info[4][0]).split("%")[0] # strip any IPv6 scope id + if ip in seen: + continue + try: + if qtype == "A" and fam == socket.AF_INET: + rdata = A(ip) + elif qtype == "AAAA" and fam == socket.AF_INET6: + # macOS returns v4-mapped (::ffff:1.2.3.4) for AAAA; those + # are not real IPv6 and the guest network is IPv4-only. + if ip.startswith("::ffff:") or "." in ip: + continue + rdata = AAAA(ip) + else: + continue + except Exception: + logger.debug("Skipping unparseable address %s for %s", ip, name) + continue + seen.add(ip) + reply.add_answer(RR(request.q.qname, request.q.qtype, rdata=rdata, ttl=60)) + + return reply + + +class HostDnsProxy: + """A loopback DNS server that resolves via the host OS resolver. + + Lifecycle mirrors :class:`~quicksand_core.host.smb.SMBServer`: construct, + :meth:`start`, then :meth:`stop`. The chosen :attr:`port` is passed to the + patched libslirp via the ``QUICKSAND_DNS_PROXY`` environment variable. + """ + + def __init__(self) -> None: + self._port = find_free_port() + self._servers: list = [] + + @property + def port(self) -> int: + return self._port + + def start(self) -> None: + from dnslib.server import DNSServer + + resolver = _GetAddrInfoResolver() + for tcp in (False, True): + server = DNSServer(resolver, port=self._port, address="127.0.0.1", tcp=tcp) + server.start_thread() + self._servers.append(server) + logger.info("Host DNS proxy listening on 127.0.0.1:%d (udp+tcp)", self._port) + + def stop(self) -> None: + for server in self._servers: + try: + server.stop() + except Exception: + logger.debug("Error stopping DNS proxy server", exc_info=True) + self._servers = [] diff --git a/packages/quicksand-core/quicksand_core/sandbox/_lifecycle.py b/packages/quicksand-core/quicksand_core/sandbox/_lifecycle.py index 504c7f2..2148139 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_lifecycle.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_lifecycle.py @@ -7,6 +7,7 @@ import os import secrets import shutil +import sys import tempfile import warnings from collections.abc import Callable @@ -147,6 +148,12 @@ def _launch_process(self) -> None: if self.config.network_mode in (NetworkMode.MOUNTS_ONLY, NetworkMode.FULL): self._ensure_smb_server() + # Start the host DNS proxy (macOS + FULL networking). The bundled + # libslirp is patched to redirect guest DNS to QUICKSAND_DNS_PROXY, + # which this proxy answers via the OS resolver — fixing VPN/split-DNS + # failures that the stock libslirp hits on macOS. + self._maybe_start_dns_proxy() + cmd = self._build_vm_command() logger.debug( "Starting VM: port=%s, image=%s", @@ -163,10 +170,37 @@ def _launch_process(self) -> None: ): env[EnvironmentVariables.QEMU_MODULE_DIR] = str(self._runtime_info.module_dir) + if self._dns_proxy is not None: + env[EnvironmentVariables.DNS_PROXY] = str(self._dns_proxy.port) + assert self._temp_dir is not None console_log_path = self._temp_dir / FilePatterns.CONSOLE_LOG self._process_manager.start(cmd, env, console_log_path) + def _maybe_start_dns_proxy(self) -> None: + """Start the host DNS proxy when appropriate. + + Auto-enabled on macOS hosts with ``network_mode=FULL`` (the only mode + where the guest reaches the internet). ``config.host_dns_proxy`` forces + it on or off. The proxy is the host-side endpoint of the patched + libslirp's DNS redirect; see :mod:`quicksand_core.host.dns_proxy`. + """ + override = self.config.host_dns_proxy + if override is False: + return + if sys.platform != "darwin": + if override is True: + logger.warning("host_dns_proxy is only supported on macOS hosts; ignoring") + return + if override is None and self.config.network_mode is not NetworkMode.FULL: + return + + from ..host.dns_proxy import HostDnsProxy + + proxy = HostDnsProxy() + proxy.start() + self._dns_proxy = proxy + async def _connect_to_guest_agent(self) -> None: """Wait for the guest agent; clean up and re-raise on failure.""" try: @@ -360,6 +394,14 @@ async def _cleanup(self) -> None: cleanup_errors.extend(await self._cleanup_mounts()) + if self._dns_proxy is not None: + try: + self._dns_proxy.stop() + except Exception as e: + cleanup_errors.append(("DNS proxy", e)) + logger.warning("Failed to stop DNS proxy: %s", e) + self._dns_proxy = None + if self._agent_client: try: await self._agent_client.close() diff --git a/packages/quicksand-core/quicksand_core/sandbox/_protocol.py b/packages/quicksand-core/quicksand_core/sandbox/_protocol.py index cfd6733..0ad665e 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_protocol.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_protocol.py @@ -16,6 +16,7 @@ SaveManifest, Timeouts, ) +from ..host.dns_proxy import HostDnsProxy from ..host.smb import SMBServer from ..qemu import OverlayManager, VMProcessManager from ..qemu.platform import RuntimeInfo @@ -43,6 +44,7 @@ class _SandboxProtocol(Protocol): _temp_dir: Path | None _process_manager: VMProcessManager _smb_server: SMBServer | None + _dns_proxy: HostDnsProxy | None _dynamic_mounts: list[MountHandle] _progress_callback: Callable[[str, int, int], None] | None _overlay_manager: OverlayManager | None diff --git a/packages/quicksand-core/quicksand_core/sandbox/_sandbox.py b/packages/quicksand-core/quicksand_core/sandbox/_sandbox.py index 513def0..375b001 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_sandbox.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_sandbox.py @@ -93,6 +93,7 @@ def __init__( self._temp_dir: Path | None = None self._process_manager = VMProcessManager() self._smb_server: Any = None + self._dns_proxy: Any = None self._dynamic_mounts: list = [] self._agent_client: AgentClient | None = None self._agent_port: int | None = None diff --git a/packages/quicksand-qemu/hatch_build.py b/packages/quicksand-qemu/hatch_build.py index 2f43fd5..3cac89b 100644 --- a/packages/quicksand-qemu/hatch_build.py +++ b/packages/quicksand-qemu/hatch_build.py @@ -990,6 +990,134 @@ def _install_qemu_windows(self) -> None: "The installer may have failed silently." ) + def _ensure_libslirp_build_tools(self) -> None: + """Install meson/ninja (needed to build patched libslirp) if missing.""" + missing = [t for t in ("meson", "ninja") if not shutil.which(t)] + if not missing: + return + if not shutil.which("brew"): + raise RuntimeError( + "Building patched libslirp requires meson and ninja, but they are " + "missing and Homebrew is not available to install them." + ) + subprocess.run(["brew", "install", *missing], check=True) + self.app.display_info(f"Installed build tools via Homebrew: {', '.join(missing)}") + + def _patch_libslirp_macos(self, bin_dir: Path) -> None: + """Rebuild the bundled libslirp with the quicksand DNS-redirect patch. + + Stock macOS libslirp forwards guest DNS to a single libresolv-picked + resolver and ignores the system's scoped/VPN/split-DNS resolvers, so + guest DNS fails (often entirely) under a VPN. The patch makes libslirp + honor ``$QUICKSAND_DNS_PROXY`` and redirect guest DNS to a host-side + proxy (``quicksand_core.host.dns_proxy``) that resolves via the OS + resolver. We rebuild the *same* libslirp version Homebrew's QEMU links + (ABI-compatible, same SONAME) and swap the dylib into ``bin/lib/``. + """ + import tarfile + import tempfile + import urllib.request + + lib_dir = bin_dir / "lib" + target = lib_dir / "libslirp.0.dylib" + if not target.exists(): + raise RuntimeError( + f"Expected bundled libslirp at {target}, but it is missing — " + "cannot apply the quicksand DNS patch." + ) + + patch_file = Path(self.root) / "patches" / "libslirp-dns-proxy.patch" + if not patch_file.exists(): + raise RuntimeError(f"libslirp patch not found: {patch_file}") + + # Guard: we only patch libslirp.0 (SONAME). If QEMU ever links a + # different SONAME (e.g. a future libslirp.1), swapping libslirp.0 + # would silently ship an unpatched QEMU — fail loudly instead. + qemu_bin = next(bin_dir.glob("qemu-system-*"), None) + if qemu_bin is not None: + linked = subprocess.run( + ["otool", "-L", str(qemu_bin)], capture_output=True, text=True + ).stdout + if "libslirp.0.dylib" not in linked: + raise RuntimeError( + "Bundled QEMU does not link libslirp.0.dylib — the pinned " + "libslirp patch version is stale. Update LIBSLIRP_VERSION and " + "patches/libslirp-dns-proxy.patch to match QEMU's libslirp." + ) + + # Pinned: the patch in patches/ is cut against this exact version, and + # it is ABI-compatible (same SONAME) with the libslirp QEMU links. + # Bump together with the patch when QEMU moves to a newer libslirp. + LIBSLIRP_VERSION = "4.9.3" + source_url = ( + f"https://gitlab.freedesktop.org/slirp/libslirp/-/archive/" + f"v{LIBSLIRP_VERSION}/libslirp-v{LIBSLIRP_VERSION}.tar.gz" + ) + version = LIBSLIRP_VERSION + + self._ensure_libslirp_build_tools() + + # Build env: ensure Homebrew's bin (meson/ninja/pkg-config) is on PATH. + env = dict(os.environ) + brew = shutil.which("brew") + if brew: + brew_bin = str(Path(brew).resolve().parent) + if brew_bin not in env.get("PATH", ""): + env["PATH"] = f"{brew_bin}:{env.get('PATH', '')}" + + with tempfile.TemporaryDirectory(prefix="quicksand-libslirp-") as tmp_str: + tmp = Path(tmp_str) + tarball = tmp / "libslirp-src.tar.gz" + self.app.display_info(f"Downloading libslirp {version} source from {source_url}") + urllib.request.urlretrieve(source_url, tarball) + with tarfile.open(tarball) as tf: + tf.extractall(tmp) + + src = next(p for p in tmp.iterdir() if p.is_dir() and (p / "meson.build").exists()) + + # Apply the patch (paths are a/src/... b/src/... -> -p1, relative to src). + with patch_file.open() as pf: + subprocess.run(["patch", "-p1"], cwd=src, stdin=pf, check=True) + + build = src / "build" + subprocess.run( + ["meson", "setup", str(build), "--buildtype=release"], + cwd=src, + check=True, + env=env, + ) + subprocess.run(["ninja", "-C", str(build)], check=True, env=env) + + built = build / "libslirp.0.dylib" + if not built.exists(): + raise RuntimeError(f"libslirp build did not produce {built}") + + # Match the bundle layout: self-id and @loader_path sibling deps. + subprocess.run( + ["install_name_tool", "-id", "@loader_path/libslirp.0.dylib", str(built)], + check=True, + ) + otool = subprocess.run( + ["otool", "-L", str(built)], capture_output=True, text=True, check=True + ) + for line in otool.stdout.splitlines()[1:]: + dep = line.strip().split(" ")[0] + if not dep or dep.startswith("@"): + continue + base = Path(dep).name + if base == "libslirp.0.dylib" or not (lib_dir / base).exists(): + continue + subprocess.run( + ["install_name_tool", "-change", dep, f"@loader_path/{base}", str(built)], + check=True, + ) + + shutil.copy(built, target) + + # Re-sign ad-hoc (install_name_tool invalidated any signature). + subprocess.run(["codesign", "--force", "--sign", "-", str(target)], check=True) + self.app.display_info(f"Bundled patched libslirp {version} (QUICKSAND_DNS_PROXY redirect)") + def initialize(self, version: str, build_data: dict) -> None: """Bundle QEMU binaries into the package.""" if self.target_name != "wheel" or version == "editable": @@ -1034,6 +1162,9 @@ def initialize(self, version: str, build_data: dict) -> None: if system == "darwin": bundler.bundle_macos_dylibs(dest_qemu, bin_dir, entitlements_plist=MACOS_ENTITLEMENTS) bundler.bundle_macos_dylibs(dest_img, bin_dir) + # Replace the bundled libslirp with a patched build that honors + # $QUICKSAND_DNS_PROXY (fixes macOS VPN/split-DNS guest DNS). + self._patch_libslirp_macos(bin_dir) elif system == "linux": bundler.bundle_linux_libs(dest_qemu, bin_dir) bundler.bundle_linux_libs(dest_img, bin_dir) diff --git a/packages/quicksand-qemu/patches/libslirp-dns-proxy.patch b/packages/quicksand-qemu/patches/libslirp-dns-proxy.patch new file mode 100644 index 0000000..c70aad2 --- /dev/null +++ b/packages/quicksand-qemu/patches/libslirp-dns-proxy.patch @@ -0,0 +1,57 @@ +diff --git a/src/socket.c b/src/socket.c +index c491d0f..6d18a53 100644 +--- a/src/socket.c ++++ b/src/socket.c +@@ -725,6 +725,19 @@ void sorecvfrom(struct socket *so) + saddr = addr; + sotranslate_in(so, &saddr); + ++ /* ++ * Quicksand: when DNS forwarding is redirected to a host proxy on ++ * a non-53 port ($QUICKSAND_DNS_PROXY), the reply arrives from that ++ * port; restore the source port the guest queried (so_fport, i.e. ++ * 53) so the guest's resolver accepts the reply. No-op in the normal ++ * case, where the real resolver already answers from :53. ++ */ ++ if (so->so_ffamily == AF_INET && !so->slirp->disable_dns && ++ so->so_faddr.s_addr == so->slirp->vnameserver_addr.s_addr && ++ so->so_fport == htons(53)) { ++ ((struct sockaddr_in *)&saddr)->sin_port = so->so_fport; ++ } ++ + /* Perform lazy guest IP address resolution if needed. */ + if (so->so_state & SS_HOSTFWD) { + if (soassign_guest_addr_if_needed(so) < 0) { +@@ -976,8 +989,30 @@ void sofwdrain(struct socket *so) + static bool sotranslate_out4(Slirp *s, struct socket *so, struct sockaddr_in *sin) + { + if (!s->disable_dns && so->so_faddr.s_addr == s->vnameserver_addr.s_addr) { +- return (so->so_fport == htons(53) && +- get_dns_addr(&sin->sin_addr, &sin->sin_port) >= 0); ++ if (so->so_fport != htons(53)) { ++ return false; ++ } ++ /* ++ * Quicksand: when $QUICKSAND_DNS_PROXY is set to a port number, ++ * redirect the upstream DNS that slirp forwards (guest -> 10.0.2.3) ++ * to a host-side proxy on 127.0.0.1:. This bypasses the macOS ++ * libresolv path (get_dns_addr) that picks a single primary resolver ++ * and ignores VPN/split-DNS scoping. Unset => unchanged behavior. ++ */ ++ { ++ static int qs_dns_port = -1; /* -1 = unparsed, 0 = disabled */ ++ if (qs_dns_port == -1) { ++ const char *e = getenv("QUICKSAND_DNS_PROXY"); ++ int p = (e && *e) ? atoi(e) : 0; ++ qs_dns_port = (p > 0 && p <= 65535) ? p : 0; ++ } ++ if (qs_dns_port > 0) { ++ sin->sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ sin->sin_port = htons((uint16_t)qs_dns_port); ++ return true; ++ } ++ } ++ return get_dns_addr(&sin->sin_addr, &sin->sin_port) >= 0; + } + + if (so->so_faddr.s_addr == s->vhost_addr.s_addr || diff --git a/tests/unit/test_dns_proxy.py b/tests/unit/test_dns_proxy.py new file mode 100644 index 0000000..7a7a4cd --- /dev/null +++ b/tests/unit/test_dns_proxy.py @@ -0,0 +1,89 @@ +"""Unit tests for the macOS host DNS proxy (host/dns_proxy.py) and its config. + +The proxy is macOS-only (dnslib is a macOS-only dependency and the patched +libslirp that drives it is only bundled on macOS), so the functional tests +skip elsewhere. The config/env-name tests run everywhere. +""" + +from __future__ import annotations + +import socket +import sys + +import pytest +from quicksand_core import SandboxConfig +from quicksand_core._types import EnvironmentVariables + +darwin_only = pytest.mark.skipif(sys.platform != "darwin", reason="host DNS proxy is macOS-only") + + +def _query(port: int, name: str, qtype: str = "A"): + from dnslib import DNSRecord + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(3) + try: + sock.sendto(DNSRecord.question(name, qtype).pack(), ("127.0.0.1", port)) + data, _ = sock.recvfrom(4096) + finally: + sock.close() + return DNSRecord.parse(data) + + +@darwin_only +def test_proxy_resolves_localhost(): + """A records come back via getaddrinfo (no network needed for localhost).""" + from quicksand_core.host.dns_proxy import HostDnsProxy + + proxy = HostDnsProxy() + proxy.start() + try: + reply = _query(proxy.port, "localhost", "A") + answers = [str(rr.rdata) for rr in reply.rr] + assert "127.0.0.1" in answers + finally: + proxy.stop() + + +@darwin_only +def test_proxy_empty_for_unresolvable(): + """Unresolvable names yield an empty NOERROR reply, never a crash/hang.""" + from quicksand_core.host.dns_proxy import HostDnsProxy + + proxy = HostDnsProxy() + proxy.start() + try: + reply = _query(proxy.port, "quicksand-nope.invalid", "A") + assert len(reply.rr) == 0 + finally: + proxy.stop() + + +@darwin_only +def test_proxy_aaaa_does_not_return_v4mapped(): + """macOS getaddrinfo returns v4-mapped AAAA; the proxy must drop them + (guest slirp network is IPv4-only) and never emit a malformed record.""" + from quicksand_core.host.dns_proxy import HostDnsProxy + + proxy = HostDnsProxy() + proxy.start() + try: + reply = _query(proxy.port, "localhost", "AAAA") + for rr in reply.rr: + assert "." not in str(rr.rdata) # no v4-mapped leak + finally: + proxy.stop() + + +def test_config_field_default_is_auto(): + assert SandboxConfig(image="alpine").host_dns_proxy is None + + +def test_config_field_override(): + assert SandboxConfig(image="alpine", host_dns_proxy=True).host_dns_proxy is True + assert SandboxConfig(image="alpine", host_dns_proxy=False).host_dns_proxy is False + + +def test_dns_proxy_env_var_name(): + # The bundled patched libslirp reads exactly this variable. + assert EnvironmentVariables.DNS_PROXY == "QUICKSAND_DNS_PROXY" diff --git a/uv.lock b/uv.lock index b02104f..92aacd4 100644 --- a/uv.lock +++ b/uv.lock @@ -375,6 +375,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "dnslib" +version = "0.9.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/71/269f74ef9bc8ca453af2e1768d4f4c8e7ef5f894d058d27fd1b69c754d7f/dnslib-0.9.26.tar.gz", hash = "sha256:be56857534390b2fbd02935270019bacc5e6b411d156cb3921ac55a7fb51f1a8", size = 82901, upload-time = "2025-03-03T09:17:32.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/93/167364f10194e374119b211e475ed8763d7591ae4f7001b304282dde5825/dnslib-0.9.26-py3-none-any.whl", hash = "sha256:e68719e633d761747c7e91bd241019ef5a2b61a63f56025939e144c841a70e0d", size = 64161, upload-time = "2025-03-03T09:17:31.47Z" }, +] + [[package]] name = "filelock" version = "3.29.0" @@ -1075,6 +1084,7 @@ name = "quicksand-core" version = "0.11.14.dev0" source = { editable = "packages/quicksand-core" } dependencies = [ + { name = "dnslib", marker = "sys_platform == 'darwin'" }, { name = "httpx" }, { name = "pydantic" }, { name = "quicksand-smb" }, @@ -1082,6 +1092,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "dnslib", marker = "sys_platform == 'darwin'", specifier = ">=0.9.24" }, { name = "httpx", specifier = ">=0.27,<1.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "quicksand-smb", editable = "packages/quicksand-smb" }, From a83b30450230ce4382f1f4947b14c4b50a63ff33 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Thu, 4 Jun 2026 16:17:37 -0400 Subject: [PATCH 02/12] Release quicksand-core v0.11.14a0, quicksand-qemu v0.5.11a0 (alpha) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/quicksand-core/pyproject.toml | 2 +- packages/quicksand-qemu/pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index 6bd5b19..427f040 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-core" -version = "0.11.14.dev0" +version = "0.11.14a0" description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/packages/quicksand-qemu/pyproject.toml b/packages/quicksand-qemu/pyproject.toml index 760fd7f..623cca5 100644 --- a/packages/quicksand-qemu/pyproject.toml +++ b/packages/quicksand-qemu/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-qemu" -version = "0.5.11.dev0" +version = "0.5.11a0" description = "Bundled QEMU binaries for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 92aacd4..d41e71c 100644 --- a/uv.lock +++ b/uv.lock @@ -1081,7 +1081,7 @@ source = { editable = "packages/dev/quicksand-build-tools" } [[package]] name = "quicksand-core" -version = "0.11.14.dev0" +version = "0.11.14a0" source = { editable = "packages/quicksand-core" } dependencies = [ { name = "dnslib", marker = "sys_platform == 'darwin'" }, @@ -1160,7 +1160,7 @@ requires-dist = [ [[package]] name = "quicksand-qemu" -version = "0.5.11.dev0" +version = "0.5.11a0" source = { editable = "packages/quicksand-qemu" } [[package]] From e7e3e12b7802665e73f70705732e70128244309d Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Thu, 4 Jun 2026 16:57:13 -0400 Subject: [PATCH 03/12] docs: changelog for macOS DNS proxy alpha Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b6964..052e3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the quicksand project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [quicksand-core v0.11.14a0, quicksand-qemu v0.5.11a0] - 2026-06-04 + +Alpha release fixing macOS guest DNS failures under a VPN. + +### Added +- **quicksand-core:** `SandboxConfig.host_dns_proxy` and a host-side DNS proxy (`quicksand_core.host.dns_proxy.HostDnsProxy`) that resolves guest DNS via the host OS resolver (`getaddrinfo`). Auto-enabled on macOS hosts with `network_mode=FULL`. Fixes intermittent — often total — guest DNS failures on macOS while connected to a VPN, where stock libslirp forwards DNS to a single libresolv-picked resolver and ignores the system's scoped/split-DNS configuration. +- **quicksand-qemu:** The bundled macOS libslirp is rebuilt from source with a patch that redirects guest DNS to `$QUICKSAND_DNS_PROXY` (the host proxy above). Inert unless that variable is set; Linux and Windows wheels are unchanged. + ## [v0.11.14] - 2026-05-14 ### Fixed From 602424d56630c881c0bb30ac6cf815449e026860 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Mon, 8 Jun 2026 15:38:53 -0400 Subject: [PATCH 04/12] fix(ci): install dnslib in dev group so ty resolves it on non-macOS quicksand-core's dnslib dep is macOS-only at runtime; CI type-checks on Linux where it wasn't installed, so 'ty check' failed on the dnslib imports. Add it to the dev dependency group (not a published runtime dep) so the type checker resolves it everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 3 +++ uv.lock | 1 + 2 files changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 68efd97..fb5f72b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ dev = [ "hatchling", "uv-release>=0.40.0", "pre-commit>=4.6.0", + # macOS-only runtime dep of quicksand-core; installed here so `ty` can + # resolve it when type-checking on Linux/Windows CI runners. + "dnslib>=0.9.24", ] [tool.poe.tasks] diff --git a/uv.lock b/uv.lock index d41e71c..f6ec50d 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,7 @@ overrides = [{ name = "httpx", specifier = ">=0.27,<1" }] [manifest.dependency-groups] dev = [ + { name = "dnslib", specifier = ">=0.9.24" }, { name = "hatchling" }, { name = "poethepoet" }, { name = "pre-commit", specifier = ">=4.6.0" }, From 99bab2e91b1d0b22f475d61810b977692f020034 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Mon, 8 Jun 2026 15:58:43 -0400 Subject: [PATCH 05/12] ci(release): use 'uv run --no-sync' in build step (cross-platform) 'source .venv/bin/activate' is Unix-only and fails on the Windows runner (PowerShell, and uv puts the activate script under .venv/Scripts). Use 'uv run --no-sync' so the build command runs in the synced venv on all platforms. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f99d150..33eb3e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,9 +96,8 @@ jobs: QUICKSAND_AUTO_INSTALL: "1" run: | uv venv --seed --clear - source .venv/bin/activate uv sync --frozen - uvr jobs build + uv run --no-sync uvr jobs build - id: upload uses: actions/upload-artifact@v4 with: From de23ea9d29642fa0ba9decda619c65289432bd92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Jun 2026 20:08:15 +0000 Subject: [PATCH 06/12] chore: bump to next dev versions quicksand-qemu: 0.5.11a1.dev0 quicksand-core: 0.11.14a1.dev0 --- packages/quicksand-core/pyproject.toml | 2 +- packages/quicksand-qemu/pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index 427f040..a90e400 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-core" -version = "0.11.14a0" +version = "0.11.14a1.dev0" description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/packages/quicksand-qemu/pyproject.toml b/packages/quicksand-qemu/pyproject.toml index 623cca5..f6324c6 100644 --- a/packages/quicksand-qemu/pyproject.toml +++ b/packages/quicksand-qemu/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-qemu" -version = "0.5.11a0" +version = "0.5.11a1.dev0" description = "Bundled QEMU binaries for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index f6ec50d..5b2a878 100644 --- a/uv.lock +++ b/uv.lock @@ -1082,7 +1082,7 @@ source = { editable = "packages/dev/quicksand-build-tools" } [[package]] name = "quicksand-core" -version = "0.11.14a0" +version = "0.11.14a1.dev0" source = { editable = "packages/quicksand-core" } dependencies = [ { name = "dnslib", marker = "sys_platform == 'darwin'" }, @@ -1161,7 +1161,7 @@ requires-dist = [ [[package]] name = "quicksand-qemu" -version = "0.5.11a0" +version = "0.5.11a1.dev0" source = { editable = "packages/quicksand-qemu" } [[package]] From c11a5e7bfbd5d1cb75b716fe29d8208833645551 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Mon, 8 Jun 2026 17:49:30 -0400 Subject: [PATCH 07/12] fix(dns): route dnslib per-request logs to DEBUG The host DNS proxy used dnslib's default logger, which prints every request/reply to stdout at all times. Route it to logger.debug so the per-query chatter is suppressed at the default log level. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../quicksand-core/quicksand_core/host/dns_proxy.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/quicksand-core/quicksand_core/host/dns_proxy.py b/packages/quicksand-core/quicksand_core/host/dns_proxy.py index b9b247e..ba0013a 100644 --- a/packages/quicksand-core/quicksand_core/host/dns_proxy.py +++ b/packages/quicksand-core/quicksand_core/host/dns_proxy.py @@ -99,11 +99,17 @@ def port(self) -> int: return self._port def start(self) -> None: - from dnslib.server import DNSServer + from dnslib.server import DNSLogger, DNSServer resolver = _GetAddrInfoResolver() + # Route dnslib's per-request/reply chatter to our logger at DEBUG so it + # is suppressed at the default level (prefix=False drops dnslib's own + # timestamp since the logging framework adds one). + dns_logger = DNSLogger(prefix=False, logf=logger.debug) for tcp in (False, True): - server = DNSServer(resolver, port=self._port, address="127.0.0.1", tcp=tcp) + server = DNSServer( + resolver, port=self._port, address="127.0.0.1", tcp=tcp, logger=dns_logger + ) server.start_thread() self._servers.append(server) logger.info("Host DNS proxy listening on 127.0.0.1:%d (udp+tcp)", self._port) From c6264662ede8f992fb72f7aa15686240d9fb45d2 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Mon, 8 Jun 2026 17:50:14 -0400 Subject: [PATCH 08/12] Release quicksand-core v0.11.14a1 (alpha) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/quicksand-core/pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index a90e400..a7efb7a 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-core" -version = "0.11.14a1.dev0" +version = "0.11.14a1" description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 5b2a878..79d297c 100644 --- a/uv.lock +++ b/uv.lock @@ -1082,7 +1082,7 @@ source = { editable = "packages/dev/quicksand-build-tools" } [[package]] name = "quicksand-core" -version = "0.11.14a1.dev0" +version = "0.11.14a1" source = { editable = "packages/quicksand-core" } dependencies = [ { name = "dnslib", marker = "sys_platform == 'darwin'" }, From 97b6597fa8a53a9104296bb2b0625cb9690712a5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Jun 2026 21:57:41 +0000 Subject: [PATCH 09/12] chore: bump to next dev versions quicksand-core: 0.11.14a2.dev0 --- packages/quicksand-core/pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index a7efb7a..174f6f0 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-core" -version = "0.11.14a1" +version = "0.11.14a2.dev0" description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 79d297c..19bf3a7 100644 --- a/uv.lock +++ b/uv.lock @@ -1082,7 +1082,7 @@ source = { editable = "packages/dev/quicksand-build-tools" } [[package]] name = "quicksand-core" -version = "0.11.14a1" +version = "0.11.14a2.dev0" source = { editable = "packages/quicksand-core" } dependencies = [ { name = "dnslib", marker = "sys_platform == 'darwin'" }, From d15128fb66b472904245815d25bb08d2e42d88b7 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Mon, 22 Jun 2026 12:21:59 -0400 Subject: [PATCH 10/12] fix(dns): disable dnslib request/reply log hooks to quiet host logs The per-request/reply chatter surfaced even at DEBUG once a guest did any DNS. Disable the request/reply/truncated hooks entirely and keep only error logging routed to our logger at DEBUG. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../quicksand-core/quicksand_core/host/dns_proxy.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/quicksand-core/quicksand_core/host/dns_proxy.py b/packages/quicksand-core/quicksand_core/host/dns_proxy.py index ba0013a..b6e3d6c 100644 --- a/packages/quicksand-core/quicksand_core/host/dns_proxy.py +++ b/packages/quicksand-core/quicksand_core/host/dns_proxy.py @@ -102,10 +102,12 @@ def start(self) -> None: from dnslib.server import DNSLogger, DNSServer resolver = _GetAddrInfoResolver() - # Route dnslib's per-request/reply chatter to our logger at DEBUG so it - # is suppressed at the default level (prefix=False drops dnslib's own - # timestamp since the logging framework adds one). - dns_logger = DNSLogger(prefix=False, logf=logger.debug) + # dnslib logs every request/reply, which floods host logs once a guest + # does any DNS (and shows even at DEBUG). Disable those chatty hooks + # entirely and keep only errors, routed to our logger at DEBUG + # (prefix=False drops dnslib's own timestamp since the logging framework + # adds one). + dns_logger = DNSLogger(log="-request,-reply,-truncated", prefix=False, logf=logger.debug) for tcp in (False, True): server = DNSServer( resolver, port=self._port, address="127.0.0.1", tcp=tcp, logger=dns_logger From a62f5c6be22fb34cf643d399fe95f6c856d831da Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Mon, 22 Jun 2026 14:59:20 -0400 Subject: [PATCH 11/12] Release quicksand-core v0.11.14, quicksand-qemu v0.5.11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ packages/quicksand-core/pyproject.toml | 2 +- packages/quicksand-qemu/pyproject.toml | 2 +- uv.lock | 4 ++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 052e3fc..6683ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to the quicksand project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [quicksand-core v0.11.14, quicksand-qemu v0.5.11] - 2026-06-22 + +Stable release of the macOS VPN/split-DNS fix (previously alpha). + +### Added +- **quicksand-core:** `SandboxConfig.host_dns_proxy` and a host-side DNS proxy (`quicksand_core.host.dns_proxy.HostDnsProxy`) that resolves guest DNS via the host OS resolver (`getaddrinfo`). Auto-enabled on macOS hosts with `network_mode=FULL`. Fixes intermittent — often total — guest DNS failures on macOS while connected to a VPN, where stock libslirp forwards DNS to a single libresolv-picked resolver and ignores the system's scoped/split-DNS configuration. +- **quicksand-qemu:** The bundled macOS libslirp is rebuilt from source with a patch that redirects guest DNS to `$QUICKSAND_DNS_PROXY` (the host proxy above). Inert unless that variable is set; Linux and Windows wheels are unchanged. + +### Fixed +- **quicksand-core:** Silenced dnslib's per-request/reply logging, which flooded host logs (even at DEBUG) once a guest did any DNS. Only error logging remains, routed to the `quicksand.dns_proxy` logger at DEBUG. + ## [quicksand-core v0.11.14a0, quicksand-qemu v0.5.11a0] - 2026-06-04 Alpha release fixing macOS guest DNS failures under a VPN. diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index 174f6f0..958d0aa 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-core" -version = "0.11.14a2.dev0" +version = "0.11.14" description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/packages/quicksand-qemu/pyproject.toml b/packages/quicksand-qemu/pyproject.toml index f6324c6..697b7d9 100644 --- a/packages/quicksand-qemu/pyproject.toml +++ b/packages/quicksand-qemu/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-qemu" -version = "0.5.11a1.dev0" +version = "0.5.11" description = "Bundled QEMU binaries for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 19bf3a7..93532e7 100644 --- a/uv.lock +++ b/uv.lock @@ -1082,7 +1082,7 @@ source = { editable = "packages/dev/quicksand-build-tools" } [[package]] name = "quicksand-core" -version = "0.11.14a2.dev0" +version = "0.11.14" source = { editable = "packages/quicksand-core" } dependencies = [ { name = "dnslib", marker = "sys_platform == 'darwin'" }, @@ -1161,7 +1161,7 @@ requires-dist = [ [[package]] name = "quicksand-qemu" -version = "0.5.11a1.dev0" +version = "0.5.11" source = { editable = "packages/quicksand-qemu" } [[package]] From 3aaac5c75121a43a703f90a804e40e4baefb7581 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 23 Jun 2026 17:33:53 +0000 Subject: [PATCH 12/12] chore: bump to next dev versions quicksand-qemu: 0.5.12.dev0 quicksand-core: 0.11.15.dev0 --- packages/quicksand-core/pyproject.toml | 2 +- packages/quicksand-qemu/pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/quicksand-core/pyproject.toml b/packages/quicksand-core/pyproject.toml index 958d0aa..a84b08a 100644 --- a/packages/quicksand-core/pyproject.toml +++ b/packages/quicksand-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-core" -version = "0.11.14" +version = "0.11.15.dev0" description = "Core sandbox functionality for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/packages/quicksand-qemu/pyproject.toml b/packages/quicksand-qemu/pyproject.toml index 697b7d9..fb94f16 100644 --- a/packages/quicksand-qemu/pyproject.toml +++ b/packages/quicksand-qemu/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quicksand-qemu" -version = "0.5.11" +version = "0.5.12.dev0" description = "Bundled QEMU binaries for the quicksand VM harness." readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 93532e7..d33e5d9 100644 --- a/uv.lock +++ b/uv.lock @@ -1082,7 +1082,7 @@ source = { editable = "packages/dev/quicksand-build-tools" } [[package]] name = "quicksand-core" -version = "0.11.14" +version = "0.11.15.dev0" source = { editable = "packages/quicksand-core" } dependencies = [ { name = "dnslib", marker = "sys_platform == 'darwin'" }, @@ -1161,7 +1161,7 @@ requires-dist = [ [[package]] name = "quicksand-qemu" -version = "0.5.11" +version = "0.5.12.dev0" source = { editable = "packages/quicksand-qemu" } [[package]]