virtdev is a per-project VM orchestrator built on KVM/QEMU. Each project gets
an isolated Arch Linux VM derived from a sealed base via qcow2 deltas. The
goal is hypervisor-level isolation between development environments as a
defense against supply-chain attacks (npm, etc.). Implemented as a set of
bash scripts in bin/ with shared helpers in lib/virtdev/.
| For | Read |
|---|---|
| User workflow, command reference, env vars | README.md |
| Architecture, threat model, lifecycle, qcow2 inheritance, locking model, ssh hardening, known limitations | DESIGN.md |
| Design specs for in-progress work | docs/superpowers/specs/ (often untracked while in development) |
| Implementation | bin/virtdev-* |
| Shared bash helpers | lib/virtdev/* |
| ISO build inputs | iso/ |
README.md and DESIGN.md are authoritative for what the project does and
how it's structured. Don't duplicate their content here — point to them.
- Starts with
#!/usr/bin/env bashandset -euo pipefail. - Uses the library bootstrap (see below) and imports what it needs by name.
- Writes errors via
error <exit-code>with the message on stdin (heredoc or here-string).error()is fromlib/virtdev/error. - Validates project-name arguments with
validate_project_name "${project}"fromlib/virtdev/validate(regex^[a-zA-Z0-9_-]+$, dynamically length-capped to fit within the 108-bytesun_pathlimit given the currentVIRTDEV_HOMEpath). - Acquires the exclusive virtdev lock with
lock_acquire(orlock_acquire_for_maintainfromvirtdev-maintain) before doing anything that mutates virtdev state. SeeDESIGN.md's "Concurrency and Locking" section for which scripts lock and why. - Defaults
VIRTDEV_HOMEvia: "${VIRTDEV_HOME:="${XDG_DATA_HOME:-${HOME}/.local/share}/virtdev"}". - Calls
arguments_parseto parse argv andarguments_usageto generate usage lines from the spec.arguments_parseintercepts--helpand-hautomatically (anywhere in argv before the--terminator) and prints the usage line; consumers don't need a manual help intercept.--color=yes|no|autois also universal — the parsed value is stored in_virtdev_color_modefor the terminal library. - Sends all user-facing messages (progress, banners, warnings, success) to stderr. Only machine-readable output (paths, port numbers, PIDs, table data) goes to stdout.
bin/virtdev is the unified entry point. virtdev start myproject
dispatches to virtdev-start myproject; virtdev help start dispatches
to virtdev-start --help. Resolution order: adjacent sibling scripts
first, then PATH. The dispatcher does not use the arguments library
(it has its own option handling since it pre-dates the library and its
parsing needs are different). Beyond the lifecycle commands (start,
stop, create, destroy, etc.), the dispatcher also routes to
query/utility commands: log, port, path, pid, status, disk,
monitor, generation, stale, and firewall.
Each library reserves the codes it uses; consumers don't override them. Same error → same code, everywhere:
| Code | Meaning | Source |
|---|---|---|
| 2 | invalid project name | validate_project_name |
| 3 | project not found | project_require |
| 64 | usage error (unknown flag, missing value, etc.) | arguments_parse |
| 75 | lock contention (BSD EX_TEMPFAIL — retry possible) |
lock_acquire* |
| 76 | lock setup failure (cannot create/open the lock file) | lock_acquire* |
| 77 | SSH key not found | ssh_key_validate |
| 78 | SSH key permissions too open | ssh_key_validate |
| 79 | invalid snapshot format | snapshot_validate_format |
| 80 | trigger aborted the command | trigger_fire |
| 81 | corrupt port file | port_require |
| 82 | corrupt generation file | generation_read |
| 83 | passt binary not found | virtdev-netexec shim |
| 84 | passt failed to initialise | virtdev-netexec shim |
| 85 | passt forward-port bind race | virtdev-netexec shim |
| 86 | QEMU command not found (pre-flight before exec) | virtdev-netexec shim |
| 87 | no port assigned (virtual machine not running) | port_require |
| 88 | host egress lockdown not active / stale baseline / wrong user | firewall_require |
| 89 | invalid zone-definition file (apply, via firewall_zone_parse) |
bin/virtdev-firewall |
| 90 | QEMU exited before active (non-passt status) | qemu_activation_classify |
| 91 | unit did not become active before deadline | qemu_activation_classify |
| 92 | apply: not invoked via sudo / target identity indeterminate | bin/virtdev-firewall |
| 93 | apply: ruleset rejected by nft -c |
bin/virtdev-firewall |
| 94 | apply: unit install / daemon-reload / enable failure | bin/virtdev-firewall |
| 95 | apply: ruleset installed but the holder failed to load it | bin/virtdev-firewall |
| 96 | holder: ruleset file missing or uid/base underivable | bin/virtdev-firewall |
| 97 | holder: table did not come up / baseline record failed | bin/virtdev-firewall |
| 98 | apply: lingering not enabled for the target user | bin/virtdev-firewall |
| 99 | holder: zone-slice cgroup dirs never appeared (pins not running) | bin/virtdev-firewall |
| 100 | apply: refused — removing the last host hole would strand a running machine's open host-loopback map | bin/virtdev-firewall |
| 101 | apply: timed out acquiring the user lock (another virtdev op in progress) | bin/virtdev-firewall |
| 102 | apply: could not enumerate running machines (manager unreachable) — fail closed | bin/virtdev-firewall |
Per-script exit codes are still numbered locally for things that aren't
factored into a library (e.g., "project not found", "VM not running").
Every exit code within a script must be unique — distinct failure
modes get distinct codes so programmatic callers (like virtdev-recreate)
can discriminate them.
#!/usr/bin/env bash
set -euo pipefail
# shellcheck disable=SC1090
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../lib/virtdev/import"
import error validate lock # whatever this script needs
# business logic followslib/virtdev/import is the bootstrap module. Sourcing it provides:
virtdev_library_directory— resolved path tolib/virtdev/virtdev_bin_directory— resolved path tobin/virtdev_loaded_libraries— associative array tracking loaded librariesimport()— source libraries by name, idempotent
The ../lib/virtdev path resolves correctly for both the dev tree
(~/dev/virtdev/bin → ~/dev/virtdev/lib/virtdev) and the pacman-installed
package (/usr/bin → /usr/lib/virtdev) because readlink -f follows
symlinks and normalises to the script's actual location. Sourced
libraries inherit the import infrastructure automatically —
composition is free.
The arguments library provides declarative flag parsing and usage
generation. A script declares its interface via associative arrays and
the library handles --long, -s short, --flag=value, --
terminator, and clustered short flags (-vy).
declare -A spec=([yes]=bool [provision]=value)
declare -A spec_short=([y]=yes [p]=provision) # optional: short aliases
declare -A spec_placeholders=([provision]=path) # optional: usage text
declare -a spec_positionals=(project) # optional: usage text
declare -A flags=()
declare -a positional=()
arguments_parse spec flags positional "$@"Spec types: bool (presence/absence), value (takes one arg —
defaults to "" if absent), required (like value, but errors if
not given). --help/-h and --color=yes|no|auto are reserved
universal flags handled before the spec is checked; declaring them
in a spec does not shadow the universal handling. The "required"
check is syntactic — --flag= counts as given; consumers needing
non-empty validation must do it after parsing.
Parsing is GNU-style: flags and positionals may be interleaved.
virtdev-destroy myproject --yes works. Use -- to force all
remaining arguments into positional regardless of prefix — required
for scripts like virtdev-ssh that pass flag-like args through.
Positional suffixes (for spec_positionals, used only by
arguments_usage): bare name = required, ? = optional, + =
variadic 1+, * = variadic 0+. Example: (project "ssh-args*").
Companion arrays are discovered by naming convention:
<spec>_short, <spec>_placeholders, <spec>_positionals.
virtdev-recreate and virtdev-upgrade use virtdev_bin_directory
(provided by the import module) to invoke sibling scripts by resolved
path, avoiding PATH ordering issues between the dev tree and the
installed package.
The trigger library provides trigger_fire <event> [sys_var proj_var].
Triggers are user-supplied executables at two levels:
${XDG_CONFIG_HOME}/virtdev/triggers/<event> (system)
${XDG_CONFIG_HOME}/virtdev/projects/<name>/triggers/<event> (per-project)
Contract:
- Standalone executables, run as child processes (not sourced).
- Inherit the caller's full environment. virtdev exports
VIRTDEV_*variables; everything else comes from the user's session naturally. - Stdout is captured by virtdev (meaning is event-specific). Stderr passes through to the terminal.
- Pre-event non-zero exit:
error 80aborts the command. Post-event non-zero exit: warning to stderr, no effect on command exit code. - No arguments — all context via environment.
- Triggers determine their own applicability. virtdev fires them
unconditionally (interactive and scripted). A trigger that should
only act in tmux checks
$TMUXitself.
Interface: trigger_fire accepts optional nameref variable names
for system and per-project output. When omitted, stdout is discarded
(used for post-ssh where output is irrelevant).
virtdev-ssh assembles configuration from four sources via a temp
file passed to ssh -F. Priority order (SSH first-match-wins):
- Per-project trigger output (most specific)
- System trigger output
${XDG_CONFIG_HOME}/virtdev/projects/<name>/ssh_config${XDG_CONFIG_HOME}/virtdev/ssh_config
Process substitution (<(...)) cannot be used because ssh opens the
-F path by name; the /dev/fd/N path is inaccessible by the time
ssh reads it. A temp file with EXIT trap cleanup is required.
The user's ~/.ssh/config is intentionally excluded. This prevents
dangerous global settings (ForwardAgent, ControlMaster) from
leaking into untrusted VM connections.
Trap composition in virtdev-ssh: ssh runs in the foreground
(not backgrounded). An EXIT trap handles all cleanup: fires post-ssh
triggers and removes the temp file. Signal traps (INT, TERM, HUP)
simply call exit which triggers the EXIT trap — they do not
perform cleanup themselves, preventing double-cleanup. A
pre_ssh_fired guard ensures post-ssh only fires if pre-ssh
succeeded.
Library files are sourced into the calling shell. They share the caller's variable scope, process, and shell options:
- Set strict mode at the top. Each library starts with
set -euo pipefail. Don't trust the caller; establish the contract on the library's own terms. - Functions and constants only. No top-level business logic. Sourcing a library must be free of side effects beyond function definitions, constants, and idempotent env-var defaulting.
- No
exit— usereturn.error()is the documented exception (intentionally terminal — treat it aspanic()). Any other helper that exits the process is a bug. localfor everything inside functions. If a global must escape, namespace it withVIRTDEV_*so the consumer can see the contract. Top-leveldeclare -Ain a library creates a variable local toimport()(since libraries are sourced from within that function). Usedeclare -gAfor associative arrays that must be global.readonlyfor true constants only. Library-level values that don't depend on mutable env vars getreadonly(ordeclare -r) so they can't be accidentally rebound by a consumer. Values derived from mutable env vars (VIRTDEV_HOME, etc.) must be computed inside the functions that use them —local -rat function entry preserves immutability without freezing the env var's source-time value (which would break any consumer that rebinds the env var afterimport).- Self-contained dependencies. Each library imports the libraries it
uses (e.g.,
validatedoesimport errorbecause it callserror()) and self-defaults the env vars it reads (e.g.,lockdefaultsVIRTDEV_HOME). The consumer doesn't need to know a library's dependency chain or env-var requirements. - Library-owned exit codes. Each library reserves the exit codes it uses and documents them in its header.
- Naming:
<library>_<verb>_<rest>. The library prefix is the subsystem noun (lock,validate); the rest follows verb-noun (acquire,acquire_for_maintain,project_name). No__prefix, novirtdev_namespace — the library is internal-only and descriptive names are sufficient. - No file extension. Files are imported as
import lock, notimport lock.sh. - Header comment documents the contract. A short block at the top names the public functions, their arguments, and any exit codes the library reserves.
The import function de-duplicates via virtdev_loaded_libraries —
sourcing the same library twice (transitively or via duplicate import
calls) is a one-line check, not a re-source. The flag is set before the
source call so circular imports terminate at the second entry rather than
recursing forever.
After any change to bin/ or lib/virtdev/:
bash -n bin/virtdev-* lib/virtdev/* # syntax
shellcheck bin/virtdev-* lib/virtdev/* # lintPlus a smoke test of the affected script via real invocation. Don't claim a change is done without running these. There's no formal test suite; the scripts are the contract.
.shellcheckrc enables external-sources=true so shellcheck follows
source -p into the library files when warnings are suppressed for SC1090
at the call site.
These six recurring failure modes were extracted from the 2026-04-25 review and a series of hardening passes. Walk this list before declaring a feature complete:
- Captured-but-unused metadata. Don't read a value into a variable and then never use it (or use it only for a log line that nobody reads). Either make it load-bearing, or drop it.
- Asymmetric pairs. A pre-flight check has a corresponding teardown; a setup has a corresponding cleanup; a writer has a corresponding validator. Missing the inverse half causes silent skew. Audit every "X happens here" for "where does X get cleaned up / validated / undone".
- TOCTOU on teardown. Reading state and then acting on it without
re-checking under the lock leaks a race. Particular hazards: VM
running checks, port-bind checks, file-existence checks before
rm. - Cross-process state needs sync. Anything systemd, anything fd-9
flock, anything in
${VIRTDEV_HOME}/projects/<name>/that another virtdev script might also touch — needs deliberate synchronization, not just "the lock is held".virtdev-stop's ACPI vs SIGTERM escalation,virtdev-start's post-systemd-run wait, andvirtdev-maintain's reset-failed coordination withvirtdev-stopare the canonical examples. - Doc-vs-tool drift. When the docs say "X happens" but the tool no
longer does X (or never did), users plan against the docs and get
surprised. After any behavior change, grep
README.md,DESIGN.md, and the script header comments for the old behavior. - Unprotected critical sections. Multi-step mutations (rename-aside,
move-in, write-marker, remove-old) need signal traps around the
critical section and auto-recovery on re-entry.
virtdev-detachimplements this pattern:trap '' INT TERM HUP QUITbefore the swap, restore after, and.bak-detection recovery at the top of the script.virtdev-maintainavoids the problem entirely viavirtdev-exchange(renameat2(2)withRENAME_EXCHANGE), which atomically swapssystem/andmaintenance/in a single syscall. Prefer atomic operations when possible; use signal traps when multi-step mutations cannot be avoided.
The meta-habit is "finish the 'and then what?' question" — when adding or modifying a behavior, walk forward through what depends on it and backward through what it depends on.
PKGBUILD (and .SRCINFO) ship the project as virtdev-git for the AUR.
Install layout:
bin/virtdev-*→/usr/bin/virtdev-*(mode 755)lib/virtdev/*→/usr/lib/virtdev/*(mode 644)systemd/virtdev-firewall.service→/usr/lib/systemd/system/(holder)systemd/virtdev-firewall-pin@.service→/usr/lib/systemd/user/(pin;--user, NOT system)iso/*→/usr/share/virtdev/profile/*- Docs →
/usr/share/doc/virtdev/
bash >= 5.3 required (for source -p). passt is a runtime
dependency added to depends in build/aur/PKGBUILD. .gitignore
excludes the build/ tree from makepkg.
Note: bin/virtdev-netexec is a bash shim (not compiled C), so no
GNUmakefile change is needed — the find bin/ ! -name '*.c' install
glob in PKGBUILD picks it up automatically.
VIRTDEV_HOMEdefault is${XDG_DATA_HOME:-${HOME}/.local/share}/virtdev. Every script defaults it consistently.- Project name
maintenanceis reserved byvirtdev-maintain. The reservation is enforced atvirtdev-createtime.virtdev-stopskips lock acquisition when the target ismaintenance— that's the documented abort path for a stuck maintenance session. - Sealed files (
system/*) are mode 444 bychmod 444 system/*glob invirtdev-sealandvirtdev-maintain. Adding new files tosystem/means they get swept by the chmod too — fine today, but relevant for any future in-place update to a sealed file. - Backup and restore over SSH. Both run rsync to/from the running
guest's filesystem; the host does not touch
projects/<name>/*.qcow2. Neither acquires the virtdev lock (seeDESIGN.md's "Concurrency and Locking" section for the full reasoning). The realistic concurrency hazard isvirtdev-stopmid-transfer, which manifests as a noisy rsync failure handled by the PIPESTATUS distinction in both scripts. - Generation counter.
virtdev-sealwrites the initial counter as1tosystem/generation;virtdev-maintainincrements on reseal.virtdev-createcopies the current value into the project'sprojects/<name>/generation;virtdev-startrefuses to boot if the project's counter doesn't match the base's. Valid contents: a single non-negative integer, or the literaldetached(written byvirtdev-detach). Detached projects skip the generation check entirely. Thevirtdev-listGENERATION column andvirtdev-staleuse these files. - systemd
--userunits. Project VMs run as transientvirtdev-<project>.serviceunits viasystemd-run --user.--collectis intentionally omitted so failed units persist forExecMainStatusqueries untilreset-failedclears them.virtdev-startcallsreset-failedpre-launch;virtdev-stopcalls it post-stop unless the target ismaintenance(which would racevirtdev-maintain's own reset-failed coordination). - fw_cfg hostname injection.
virtdev-startpasses-fw_cfg name=opt/virtdev/project,string=<name>to QEMU. The guest reads this at boot (via a systemd unit) and sets the machine's hostname from it. This is how each virtual machine knows its own project name without per-project disk customization. - Serial console autologin. The guest's serial console (
ttyS0) auto-logs in as thedevuser. This is emergency access for when SSH is unavailable (e.g., network misconfiguration). Reachable from the host viasocat - UNIX-CONNECT:${VIRTDEV_HOME}/projects/<project>/console.sock. - Lock visibility.
${VIRTDEV_HOME}/lockis a normal file with the current holder's PID written into it.catit during a contention error to see who holds it. The library reads/proc/<pid>/cmdlineto detect when the holder isvirtdev-maintainand emits a maintenance-specific message instead of the generic one. - passt network backend.
virtdev-startandvirtdev-maintainuse passt instead of QEMU SLIRP (-netdev user). The exec-shimbin/virtdev-netexecstarts passt (with--map-host-loopback noneand--map-guest-addr noneto block guest→host translations), thenexecs QEMU. QEMU uses-netdev stream,addr.type=unix,addr.path=<passt.sock>to connect. The keystone invariant: passt creates the socket before forking to background, so a zero exit from passt means QEMU can connect immediately. The unit'sExecMainStatusis the shim's exit code when it fails before exec (83/84/85, or 86 when the QEMU pre-flight finds no binary); after exec it is QEMU's code.virtdev-installis unchanged (keeps SLIRP).passtis a required dependency; seePKGBUILDdependsandREADME.mdRequirements. - passt.sock cleanup.
passt.socklives next tomonitor.sock,qmp.sock, andconsole.sockin the per-project directory. The socket set is single-sourced inlib/virtdev/runtime(runtime_socket_basenames), and every teardown routes throughruntime_clean(sockets + port) orruntime_clean_sockets(sockets only), so all four sockets are swept together:virtdev-stop'sstop_finalize,virtdev-maintain'smaintenance_cleanupand its pre-launch sweep inmaintenance_boot, andvirtdev-start'scleanup_failed_starttrap and pre-launch sweep (now the fullruntime_clean— it clears a stale port too, since the port is written last, after the QMP liveness confirm).virtdev-netexecunlinkspasst.sockbefore each passt start (passt_socket_clean) — passt'sbind()returnsEADDRINUSEon a leftover socket file. - Per-project directory permissions (mode 0700).
virtdev-createandvirtdev-maintaincreate project directories with mode 0700.lib/virtdev/lock(lock_open) runschmod 0700 ${VIRTDEV_HOME}on every lock acquisition to harden pre-existing installs. This protects the socket files (passt.sock,monitor.sock) from other local users. - Host egress lockdown (Phase 2 network isolation). A host-root nftables
inet virtdevtable (lib/virtdev/firewallpolicy +bin/virtdev-firewallroot tool + a resident holder unit and a--userpin template undersystemd/) filters guest egress per zone, matched to the machines' systemd--userslice cgroups.nftablesis a runtime dependency. The ruleset is project-agnostic (no per-project content) and EXTENSIBLE with custom zones: one base jump narrows tovirtdev.slice(every machine, by cgroup ancestry — scoped, catches descendants only), then one jump per slice-owning zone (built-in relaxation + customs). Key invariants:- Four built-in zones + custom zones, deny-by-default.
none(nothing but your SSH session),wan(the internet; host + LAN blocked),lan(host + LAN, no WAN),full(host + LAN + WAN — the explicit opt-out of host isolation; host and LAN move together), plus user-authored custom zones (a built-in base- per-port host holes in
~/.config/virtdev/zones/<name>, realized atapply). Built-ins are a static array (firewall_zone_known); the realized set (built-ins + customs) is the root-owned manifest/etc/virtdev/firewall/zones, the single source the rootless launch reads. The default isnone— both an omitted--zoneand an absent/invalid projectzonefile fall to it.
- per-port host holes in
- Every launch passes
--slice.firewall_slice_for <zone>→virtdev-<zone>.sliceis the single helper producing slice names;virtdev-start,virtdev-maintain, and the ruleset generator all call it. Slices are SHARED across projects (the collapse): identity stays in the unit namevirtdev-<project>.service, so there is no per-project slice and nosystemd-escape(deleted — the zone vocabulary is fixed and validated). - Per-project default zone. Host-side
${XDG_CONFIG_HOME}/virtdev/projects/<name>/zone(firewall_zone_default, fail-closed tonone, host-controlled only — the guest has no path to it).--zoneoverrides per launch;--unfilteredbypassesfirewall_requirewith a loud warning. - Resident pins materialise the cgroups. A started-but-empty slice has no
cgroup dir, so nft (which resolves cgroup paths to inodes at load) cannot
reference it.
virtdev-firewall-pin@<zone>.service(sleep infinity,Restart=always, one per relaxation zone —wan/lan/fulland every custom zone, enabled--nowand reconciled byapply) holds those slices and their parentvirtdev.slicewith stable inodes. Linger is required so they run boot→shutdown. - The guard is fail-closed on staleness.
firewall_require(and the sharedfirewall_is_activebehindstatus/list) checks the holder isactiveAND the recorded base inode (/etc/virtdev/firewall/cgroupv2, written by the HOLDER post-load) still matches the livevirtdev.slice— auser@<uid>.serviceteardown churns that inode and reads down. Nonft list(root-only); theowner,persistholder asserts the table transitively. The guard additionally requires the caller to BE the firewall's uid. It runs before the lock invirtdev-startand before both boots invirtdev-maintain; both then re-verify post-launch via the unit's liveControlGroup(firewall_assert_unit_filtered) to close the guard→launch TOCTOU, tearing the unit down on a mismatch (start exit 21 / maintain 25). applyis root, runtime is rootless.virtdev firewall apply(no sudo) self-elevates: run rootless it re-execssudo <self> apply --virtdev-home <VIRTDEV_HOME>, forwarding the user'sVIRTDEV_HOMEexplicitly across the sudo boundary (sudo strips the env) — the cleanest invocation. A directsudo virtdev firewall applystill works (no flag → falls back to the default home built from the user's home dir). apply requires linger (98), validatesSUDO_UID(never bakes a guessed uid), copies (never symlinks) the holder →/etc/systemd/systemand the pin →/etc/systemd/user(user-vs-system, NOT by file extension) and starts the pins AS THE USER, then constructs (never searches) the cgroup base, generates →nft -c→ atomicrename(2), and restarts the holder. The holder derives uid+base from the installed ruleset, waits for the pin-backed cgroup dirs (99 on timeout), loads, verifies, and recordsfirewall/cgroupv2. The package ships/usr/lib/systemd/{system,user}. apply takes the user's rootless lock (firewall_lock_user, the SAME${VIRTDEV_HOME}/lockthe launches hold) around its scan→reload→manifest-write to serialize against a concurrentvirtdev start(the start-vs-apply race), so it is NOT purely lock-free;--virtdev-homeis the ONLY input derived fromVIRTDEV_HOME, used solely to LOCATE that lock — every SECURITY decision still derives fromSUDO_UID, so a wrong value only skips coordination (fail-safe). Timing out on the lock (60 s) is exit 101. Before any mutation, apply also refuses (100) if the new zone set drops the last host-hole zone while a machine still runs with passt's host map open — read from each running machine's ownExecStart(--allow-host-loopback, the authoritative, guest-unforgeable, manifest-INDEPENDENT per-machine signal), countingactiveANDdeactivatingmachines via the sharedfirewall_running_machine_unitspredicate: passt's map is fixed at launch and apply never stops machines, so the nft loopback narrowing would un-gate that running machine to the whole host loopback — fail-OPEN. The refusal names the machines to stop first. apply removesfirewall/zonesBEFORE the holder restart (lockstep) and (re)writes it only after a successful load, so "firewall/zonesexists ⟺ the loaded ruleset was published by a completed apply" (a load that wins but skips the rewrite stays fail-CLOSED).- maintenance runs in
wan(needs WAN forpacman -Syu; trusted base, no host/LAN). Its running-machine preflight glob excludes the pins (virtdev-firewall-pin@*.service, permanent under linger).virtdev-recreate/virtdev-upgraderestore a running machine's captured zone and otherwise pass NO--zone(sovirtdev-startreads the zone file). virtdev-statusstdout is unchanged — the ZONE column lives only invirtdev-list(a ZONE token in status would breakgrep -q running).
- Four built-in zones + custom zones, deny-by-default.
- Design specs and dev narrative stay uncommitted. This repo's
convention is to leave design/brainstorm docs as untracked files in
docs/superpowers/; the user strips dev narrative and publishes distilled versions after feature completion. - Per-step verification gate. When refactoring, each commit ends in
a verified state:
bash -nclean,shellcheckclean, smoke test passes. No "let's revisit later" — fix in place before moving on.