dezhban is code-complete: every feature described in modes.md
and cli.md is implemented and covered by go test ./.... What
remains is privileged, on-host verification — the checks that need root and
a real firewall, and therefore cannot run in CI.
This file is the standing checklist. Work through the section for the OS you are
on; check the boxes as you go. The "VPN interface guard" section ends with a
macOS worked example giving literal pf commands and expected output.
Run these on a host you can afford to lock out of. Every check below arms a real kill switch. Keep a second terminal open and know the escape hatch:
sudo dezhban panicremoves all rules with no daemon running. See troubleshooting.md.
These gate everything else and should be green before you touch a firewall:
go build ./... && go vet ./... && go test ./...
GOOS=linux go build ./... && GOOS=windows go build ./...
swift build --package-path gui/macos && swift test --package-path gui/macosswift test covers DezhbanCore — the pure, AppKit-free layer (Snapshot
decoding, posture→icon derivation, settings-field batching). DezhbanMenu
itself (the AppKit/SwiftUI executable, elevation, CLI shell-out) has no test
target — see Known gaps.
Rules for go test ./... — the part of the suite that runs in CI, with no
root and no real firewall. task test:cover enforces the coverage floors in
.testcoverage.yml; these rules keep what counts toward them honest.
- No
time.Sleepin a test. Poll for the condition with a bounded deadline, or inject a clock the wayinternal/redialandinternal/decisionalready do. A sleep either wastes wall-clock waiting past the real moment or races it — neither proves the behaviour happened. Exception: a test whose whole point is a wall-clock duration itself — e.g. proving a reply survives arriving after an internal deadline has passed (TestSlowRunLoopStillGetsItsReplyThrough) — may sleep past that real duration, because the delay is the scenario, not a wait for one. Comment why when you reach for this. t.Parallel()by default. Add it to every new test unless the test usest.Setenvor otherwise mutates process-global state (t.Setenvitself already fails if paired witht.Parallel(), which is the tell).cmd/dezhbanis the standing exception — no test there may be parallel, becauserunCLIswaps the process-globalos.Stdout/os.Stderrand a parallel test anywhere in the package races it. Unliket.Setenvnothing fails on its own here, soTestNoTestInPackageMainIsParallelenforces it.- Table-driven once there are ≥3 similar cases; a plain
t.Runbelow that. A table with one or two rows is indirection with nothing to show for it. - Assert observable behaviour, not call arguments. Prefer "the firewall
policy now blocks egress" over "
Applywas called with these exact flags" — the latter breaks on refactors and proves nothing about correctness. - Every gate or refusal needs a negative case. If code can say no
(
CanActivate,canElevate, a disabled window, a policy switch), a test must prove it actually says no, not just that it says yes when allowed. - Invariant-pin tests are named contracts — extend them, never restructure.
TestWindowDisableMatrix,TestEveryLinkGoesSomewhere,TestPauseMaxDefaultAndDisableSentinel, and others like them each pin one specific past bug. Add a case to cover new ground; don't rewrite them to "clean up" — the name and the shape are the point.
- Block cuts egress.
sudo dezhban block→ general egress dies (curl https://example.comfails) but loopback works, DNS resolves (withvpn.allowPhysicalDNSon, the default), the VPN endpoint stays reachable so the tunnel can redial, and LAN devices still answer (withvpn.allowLocalNetworkon, the default). This is FULL BLOCK — it carries no destination allowlist; a VPN posture opens the tunnel endpoint. -
block --forceis total.sudo dezhban block --forcewith a tunnel up → the ruleset is loopback plusblock drop out alland nothing else. Confirm no provider pass in either shape (no destination-only pass on the physical link, no tunnel-scoped one), no endpoint pass, no port-53 rule, and no LAN pass. The tunnel drops,vpn.allowGeoProvidersmakes no difference either way, and the log says so. Recovery isdezhban unblockordezhban paniconly — verify nothing lifts it on its own. -
detect-vpn --jsonmarks an unprivileged scan partial. Run it as your user with a VPN connected whose transport runs as root →scanPrivileged: false, and the app's Diagnostics pane says the scan saw only your sockets instead of claiming none were found.sudo dezhban detect-vpn --json→scanPrivileged: trueand the candidate appears. - Status is truthful.
dezhban statusreportsblocked: true, with accurate country and service fields. - Block is idempotent. Re-run
sudo dezhban block→ no duplicate rules. - Unblock restores everything.
sudo dezhban unblock→ full connectivity; thedezhbananchor/table/group is empty; prior firewall state restored. - Teardown survives a killed process.
kill -9the daemon mid-block → network is still blocked →sudo dezhban panicrestores connectivity and removes the rules. Rules live in the kernel and on disk, not in process memory, so a fresh invocation can always tear down. - Panic is idempotent.
sudo dezhban panicon a clean system → no-op, no error. -
--forcebypasses detection.block --force/unblock --forceact without consulting the geo state. - Enforcement verification notices a ruleset removed from outside it, and
repairs it. With the daemon running and enforcing (guard or a manual
block), flush the ruleset by hand —
sudo pfctl -a dezhban -F all(macOS),sudo nft flush ruleset(Linux; a full flush, not just thedezhbantable, since this is testing that dezhban notices ANY removal), or delete the WFP rule group (Windows). Within onevpn.advanced.verifyInterval(default1m; set it lower, e.g.5s, to speed up the check) the daemon logsdezhban's firewall rules are MISSING — ... re-applying now,dezhban status --jsonshowsstate.verify.missing: truewithrepairsincremented, and the rule dump shows the ruleset back. Setvpn.advanced.verifyInterval: "0"and repeat — the daemon must NOT notice or repair (the check should stay off, not merely run slower).
Per-OS rule inspection:
| OS | Inspect the ruleset with |
|---|---|
| macOS | sudo pfctl -a dezhban -s rules |
| Linux | sudo nft list ruleset — only the dezhban table should appear |
| Windows | WFP filter dump — rules under the dezhban group/sublayer |
- Fresh install, no VPN configured → posture
standby, no rules installed (the inspect command above shows nothing for thedezhbantable/anchor), network fully open, menubar icon grey, Overview says nothing is being blocked. - Arming. Configure a tunnel and connect the VPN → the guard arms, icon goes green, and the GUARD ruleset appears.
- A pre-merge config still works. Load a config carrying
vpn.enabled,failClosedandallowlist→ it loads without error,dezhban validatenames all three as retired with a reason, and the installed ruleset is identical to the same config with those keys deleted. - Retired keys are not written back.
sudo dezhban config set logLevel debugon that config → the saved file no longer containsfailClosedorallowlist, and a re-load reports nothing retired. -
--mode legacyerrors by name rather than rendering a posture that no longer exists:dezhban print-rules --mode legacyexits non-zero and points at ADR-0001.
Only a live host can prove these — CI cannot reach a printer.
- Default on: the LAN survives arming. With the guard armed and
vpn.allowLocalNetworkunset, reach a printer / NAS / the router's admin page over its private IP → works.curl -m5 https://example.comwith the tunnel down → still fails. That pairing is the point: LAN open, internet shut. - Discovery, not just reachability. AirPlay/Chromecast targets and Bonjour printers still appear in their pickers, not merely respond when addressed directly. If they are reachable but invisible, the multicast ranges are not being passed.
- Off closes it.
vpn.allowLocalNetwork: false→ the same local device is unreachable, and the ruleset contains no RFC1918 prefixes. - It is NOT an internet path. With LAN on and the tunnel down, confirm a public address is still blocked — this is the regression test for anyone "simplifying" the destination-scoped pass into an interface-scoped one, which would silently turn the kill switch off.
- IPv6 local works too. Reach a device over its
fe80::/fc00::address with LAN on; confirm it fails with LAN off. -
dezhban statusreportsalso reachable: local network, DNS(and(nothing — tunnel and VPN server only)once both are disabled).
- A v6 VPN endpoint works end to end. Set
vpn.endpointsto an IPv6 literal → the ruleset loads and the tunnel connects. - A mixed v4+v6 endpoint set loads. Both families in
vpn.endpoints→pfctl -a dezhban -srshows aninetrule and aninet6rule, not one malformed list. - No
::ffff:form ever reaches the ruleset. After a switch window has learned an endpoint, inspectlearned.jsonandpfctl -a dezhban -sr: every address must be in canonical form. Apass out quick inet6 … to ::ffff:a.b.c.drule is the silent-lockout bug — it looks correct and matches nothing. - IPv6 egress is blocked while the guard is armed.
curl -6 -m5to a public v6 host fails; the same request through the tunnel succeeds. Test with real packets, not by reading rules — rule inspection would have called the mapped-address bug "handled".
- No guard lift during recovery. Force FULL BLOCK (block the exit's
country), then watch the logs across several probe ticks: no
apply-guard/ lift-and-re-cut cycle should appear, andpfctl -a dezhban -srmust stay on the FULL BLOCK ruleset throughout. A lift every tick is the ~8s recurring leak this replaced. - The pass is tunnel-scoped. The provider rule must read
pass out quick on { utunN } to { … }— interface and destination. A destination-only rule is the unsafe variant: it would let the lookup succeed with the tunnel down and report your ISP's country. - The measurement stays honest. Drop the tunnel while in FULL BLOCK and confirm the lookup fails rather than silently reporting the ISP's country. This check must never be "fixed" to pass by allowing the providers on the physical link — that is precisely the bug ADR-0006 exists to prevent, and it would close switch windows early on a bogus "good exit".
- The pass carries no DNS rule.
pfctl -a dezhban -srin FULL BLOCK must show no port-53 rule scoped to the tunnel (on { utunN } … port 53). Such a rule is destination-unscoped, so it would send every application's DNS through the tunnel to the forbidden exit's resolver. Ato any port 53rule with noonclause is the separate, opt-outvpn.allowPhysicalDNSpass on the physical link — that one is expected unless you set itfalse. - Rotation degrades safely, then heals. Leave the daemon in FULL BLOCK
longer than
vpn.endpointRefreshand let the providers' CDN addresses rotate. Re-resolution has no DNS path in FULL BLOCK, so the scoped pass goes stale, the lookup fails, and the posture holds (an undeterminable country never escalates). Recovery falls back to lift-and-probe, which lifts the guard — the next refresh then succeeds and the scoped pass heals itself. Confirm recovery still completes. - The fallback survives. Point
providersat an unresolvable host so no IP resolves → the daemon logs that recovery will briefly lift the guard, and recovery still works via lift-and-probe. A FULL BLOCK that can never observe its way out would be worse than the leak. - Shutdown takes no last probe. Still on the unresolvable
providersabove — the only configuration in which a probe lifts anything — hold FULL BLOCK and stop the daemon (Ctrl-Cin the foreground, orsudo dezhban stop) withpfctl -a dezhban -srpolling in another shell. The ruleset must go straight from FULL BLOCK to torn down: no lift-and-recut in between. Buying a reading on the way out opens egress through the very exit the block exists for, to observe a country nothing is left to act on.TestShutdownTakesNoProbeTickcovers the decision; only this shows the rules.
- Blocklist trips. Add the VPN exit's country to
blockedCountries→ withinhysteresisticks the posture escalates tofull-block. - Recovery. Remove it → the guard is restored.
- Clean shutdown.
Ctrl-Cwhile blocked →Cleanup()runs, connectivity is restored, exit 0. - An unknown country HOLDS — it must not escalate. Blackhole every
provider host (e.g. via
/etc/hosts) while running in GUARD → the posture staysguardhowever many error-ticks pass, and the log says the exit country is unknown. It must never reachfull-blockon errors alone: that would cut the tunnel's own egress and livelock the redial. - An exit-IP change is observed and reported, without touching posture.
Switch the VPN to a different server that still exits through an allowed
country (so
countryCode/blockedare unaffected) → the daemon logsexit IP changedanddezhban status --jsonshows a freshexitIpChangedAt. Confirm it does NOT reset on an unchanged reading, and thatpending/hysteresis progress is untouched by the comparison itself. - An unknown country does not lift a block either. Repeat while in
full-block→ it stays blocked. - An error mid-streak does not cancel a pending flip. With
hysteresis: 3, feed blocked/blocked/error/blocked → the block still commits on the fourth reading. - No flapping. An alternating country sequence must NOT toggle the
firewall until
hysteresisconsecutive readings agree. - Quorum. With three providers and one disagreeing, the majority wins and a warning is logged.
The guard is where a misconfiguration locks the host out. Run
dezhban doctor --discover first; it is designed to catch exactly that.
- Guard is up, tunnel traffic flows. With the VPN connected and the guard armed, normal browsing works.
- A tunnel drop cuts egress with no leak. Bring the tunnel interface down → all egress is cut immediately, with no physical-interface leak window. Bring it back → traffic resumes.
- Rules are interface-aware, honoring the tunnel/endpoint interface conditions — not merely destination IPs. Confirm in the rule dump.
- Guard is idempotent. Re-arming does not stack rules.
- A forbidden country escalates to FULL BLOCK, cutting the tunnel itself
(
--simulate-country IR). - An undeterminable country HOLDS the current posture rather than escalating — escalating on an unknown would cut the tunnel's own egress and livelock the redial.
- Unblock restores everything.
- A hung tunnel (interface up, no traffic) is diagnosed, not silently
left cut with no signal. With the VPN connected and the guard armed,
block the tunnel's traffic at the OS level without bringing the
interface down — e.g. a host-level firewall rule dropping packets on the
tunnel interface, or disconnect the VPN server side while the client's
interface stays configured. After
hysteresisconsecutive failed exit checks, the daemon logstunnel interface reports up, but exit lookups through it keep failing,dezhban status --jsonshowsstate.zombie.checks, anddezhban doctor's "enforcement liveness" section reports it. Confirm the guard itself is untouched throughout — still cutting egress exactly as it would for any other tunnel-up state — and that withvpn.advanced.livenessRedialat its default (false) NO switch-window rule ever appears in the rule dump. Set it totrueand repeat: a switch-window pass should appear once the streak is confirmed, through the sameredialBudget/redialMinUptimemachinery an ordinary drop uses.
Run on the local console, not over SSH/VPN/remote — a bad config or a crash
mid-block can lock you out. Keep a second terminal open with the escape hatch
(sudo pfctl -a dezhban -F all) before you start. Fill in the vpn block
first (tunnel interface via route -n get default | grep interface; the VPN
endpoint from your client's own config/logs — lsof -nP -iUDP -a -p $(pgrep -f your-vpn-process) finds it for UDP VPNs).
# Teardown works before you trust block:
sudo dezhban block --config <config>; sudo dezhban unblock
sudo pfctl -a dezhban -s rules # expect: empty anchor
# Guard up, tunnel traffic flows:
sudo dezhban block --config <config>
sudo pfctl -a dezhban -s rules # expect: pass on { utunN }, pass to { endpoint }, block drop out all
curl -m5 https://example.com # expect: succeeds (rides the tunnel)
# Tunnel drop cuts egress, no fall-through to the physical interface:
sudo ifconfig utunN down
curl -m5 https://example.com # expect: hangs/fails — redial the VPN to restore
# Forbidden country cuts the tunnel too (FULL BLOCK) — run in the foreground to force it:
sudo dezhban run --config <config> --simulate-country IR &
sudo pfctl -a dezhban -s rules # expect: only `pass quick on lo0 all` + `block drop out all`
sudo dezhban unblock # expect: connectivity back, anchor empty- Config compatibility. A pre-profiles config still loads, validates, and
renders identical rules; every file in
configs/passesdezhban validate. - Union. With two profiles, both VPNs' endpoints appear in the guard
rules, and switching between them needs no reconfiguration:
sh task rules MODE=guard CONFIG=configs/dezhban.profiles.json task rules MODE=switch CONFIG=configs/dezhban.profiles.json - The switch window behaves.
dezhban switchopens a window (state.json postureswitch-window); the daemon learns and pins the new endpoint intolearned.json, and closes the window early on a verified exit.--canceland expiry both revert to the prior fail-closed posture. - Promotion.
dezhban vpn promotemakes a learned endpoint permanent, so redialing to that VPN needs no window at all. - Import.
dezhban vpn importextracts the expected hosts from WireGuard, OpenVPN, and V2Ray configs — stripping ports, dropping private/loopback addresses, and rejecting garbage. - Dynamic tunnels. A newly-appeared tunnel is guarded within one watcher tick, with no restart. Zero tunnels up = endpoints-open standing posture, with geo suppressed.
- Automatic redial window. With a rotating-server VPN (e.g.
RocketTunnel) guarded and healthy: disconnect, then hit the client's
connect button within
vpn.redialWindow(default 30s) — the VPN redials to a fresh, never-seen server with no operator action;statusshowsredial state: OPEN(status --json:switch.trigger: "auto") while it lasts, and the menubar app posts the "VPN dropped — redial window open" notification. - Auto-window expiry fails closed. Disconnect the VPN and let the window lapse with no redial: egress is cut, STAYS cut (no second window without a tunnel-up first), and a later client connect to a known/learned endpoint still succeeds under the standing posture.
- No auto window from FULL BLOCK.
--simulate-country IR→ FULL BLOCK, then drop the tunnel: no window opens; recovery still requires the probe confirming an allowed exit (or a manualswitch). - Strict opt-out. With
vpn.redialWindow: "0", a drop opens nothing and behavior matches the pre-0.3 zero-relaxation guard.
Run all four permutations; each setting must disable only its own trigger.
-
switchWindow: "0",redialWindowdefault →dezhban switchrefuses with a message namingvpn.switchWindow, but a tunnel drop still opens the automatic redial window. -
switchWindowdefault,redialWindow: "0"→ a drop opens nothing, butdezhban switchstill works. - Both
"0"— the strict zero-leak posture. A drop is cut instantly with no window at all, anddezhban switchrefuses. Nothing can relax the guard. -
"0"survives a round trip. WithswitchWindow: "0"set, rundezhban config set logLevel debugand re-load → it is still disabled, not silently coerced back to the 15s default. (This was a real bug: the setting was accepted and discarded.)
Full live macOS pass: setup → connect VPN A (guarded) → disconnect →
dezhban switch → connect self-hosted VPN B → the window learns the endpoint and
closes → vpn promote → redial to B with no window → --simulate-country IR still escalates to FULL BLOCK → sudo dezhban panic restores.
Against a running daemon. The bug this replaced was silent: the file changed and the daemon kept enforcing the old value, so every check here is about the daemon's behaviour afterwards, never about what the file says.
- A live key takes effect with no restart.
dezhban config set pollInterval 5s→ the output saysSaved and applied: pollInterval, and the daemon's log shows geo polls at the new cadence within seconds. - A restart-required key says so instead of lying.
dezhban config set logLevel debug→Restart dezhban to apply: logLevel, and the log level is genuinely unchanged untildezhban restart. - A malformed edit does not disturb enforcement. Hand-edit the config to invalid JSON, then trigger a reload → the reload is refused with a parse error and the guard keeps enforcing the last good configuration.
- A lowered cap binds the very next window. With a pause open-able, set
vpn.pauseMaxto1m, thendezhban pause 9m→ the pause ends after 1m. (A reload that reported the cap applied while still clamping to the old, larger value was a real bug.) - Disabling a trigger live actually disables it.
dezhban config set vpn.switchWindow 0→dezhban switchrefuses immediately, without a restart, and the other two triggers still work. - Re-enabling a trigger live works too, on both paths. Start the daemon
with
vpn.switchWindowandvpn.pauseMaxboth"0"(the strict zero-leak posture), thendezhban config set vpn.switchWindow 30s→dezhban switchopens a window with no restart, and so does the root command-file path with the daemon's control socket stopped (control.enabled: false). The command poll used to be wired only when a window was enabled at startup, so this reported applied and did nothing. - A tightening lands while traffic is cut. Get the daemon into FULL BLOCK
(a forbidden exit, or
dezhban block), thendezhban config set vpn.allowLocalNetwork false→ the LAN pass is gone from the live ruleset immediately (pfctl -a dezhban -sr/nft list table inet dezhban), not only after the next posture change. - An unrelated edit does not reset a pending flip. With a forbidden exit
and
hysteresis: 3, wait forstatusto reportEscalating to full block — 1 of 3 confirming checks., thendezhban config set pollInterval 15s→ the count keeps climbing from where it was. Then changeblockedCountriesand confirm the count does restart, which is the one case where it should. - No daemon running. The write still succeeds and says so; the values are picked up at the next start.
Privileged, on a real host with a real VPN. The point of these checks is the wait: what the user sees between redialing and the guard coming back.
- Progress is visible. Force FULL BLOCK (
--simulate-country IR, or a real forbidden exit), then redial onto an allowed exit →dezhban statusshows "Restoring the guard — 1 of 2 confirming checks." and the app's Overview shows the same count, before the posture changes. - It is fast. The guard comes back within seconds of the tunnel coming
up, not after a full
pollInterval×hysteresis. - Hysteresis still gates it. With
hysteresis: 3, a single allowed reading does NOT restore the guard — it still takes three. - No acceleration when probing would leak. Break provider resolution (an
unreachable
providersURL), force FULL BLOCK, then bring the tunnel up → the daemon logs that it is not accelerating, and the probe cadence stays atpollInterval. Accelerating here would multiply real-IP exposure. - A forbidden exit that persists backs off. Stay on a blocked exit after a tunnel-up edge → probing returns to the normal cadence within ~90s instead of hammering the geo providers.
- Nothing is claimed in standby or during a window. Neither reports a pending change: the geo state machine is not driving the posture there.
Privileged for enroll/forget, macOS-relevant but not macOS-only.
- Not enrolled is a refusal, not a bypass. With no token enrolled, a
config-writeover the socket is refused. Confirmdezhban token statusreports "not enrolled". - Enroll, then write without a password.
sudo dezhban token enroll→ a token on stdout; a client presenting it can change a setting over the socket with no elevation, and the daemon adopts it in the same request. - A wrong or absent token is refused even from an account in the socket's admin group — group membership alone must not authorise a config write.
- The hash file is root-only.
ls -l /var/db/dezhban/control.token→ mode0600, owned by root. Anything that can read it can forge the proof. - The policy switch overrides a valid token. Set
control.allowConfigOps: false→ a client holding the correct token is still refused, and the message names the setting. - Re-enrolling revokes. Enroll a second time → the first token no longer works. This is the revocation path for a leaked token.
-
token forgetrecovers a stranded host. After forgetting, config changes fall back tosudorather than being impossible. - The toggle works on an ordinary ad-hoc build. On any build from
build-app.sh, "Use Touch ID for settings changes" enables, enrolls with one password prompt, and a subsequent settings change costs a fingerprint and no password. See ADR-0012. - Settings never freezes waiting on the keychain. Launch the app with the
lid shut on an external display (no usable sensor, so the launch warm-up is
skipped by design), then open the lid and go straight to Settings. The
Authorization section may read "Checking…" for a moment; the window must
stay responsive throughout. A freeze here means something reads
ControlToken.capabilityon the main thread — usecapabilityIfKnownplusresolveCapabilityinstead. Worth repeating with a locked login keychain (security lock-keychain), which is what turns the block into a modal dialog rather than a pause. - Enrollment survives an app upgrade. Enrol, then rebuild and reinstall
the app (
task devis enough — an ad-hoc rebuild changes the cdhash, and the keychain ACL is bound to it). Toggling off and on again must succeed: before the fix this failed with-25299because the new build could neither read nor delete the item the old one stored. The first read after an upgrade may ask you to approve keychain access once; that is macOS, and approving keeps the enrollment. - A cancelled fingerprint falls back to sudo, never to a login password.
Dismiss the Touch ID prompt on a settings save: the change must fall to the
ordinary privileged path, and the biometric prompt must never offer "Use
Password…" as a way through —
load()uses.deviceOwnerAuthenticationWithBiometricsprecisely so it cannot. - The app refuses enrollment it cannot complete, for free. Simulate a
failing store (e.g. temporarily point
storeat an invalid attribute set): flipping the toggle must produce no password prompt and leavedezhban token statusreporting "not enrolled" — the failure this checks for cost a password and then stranded an enrollment. See ADR-0011. - A failed store rolls the daemon back. If
SecItemAddfails aftertoken enrollhas already run,dezhban token statusmust return to "not enrolled" without the user intervening. - A failed store stops offering the stale secret — rollback or not. With a leftover keychain item the app cannot replace, force BOTH branches of the rollback: one that succeeds, and one that fails (dismiss its admin prompt). In each, the About pane's "Settings changes" must read "Password — the stored secret is stale", and a settings save must complete through the password prompt rather than failing with a daemon refusal. The flag is session-only, so check without relaunching the app.
- The About pane never invites an impossible retry. With no token enrolled, "Settings changes" must not read "turn on Touch ID in Settings" unless that toggle would actually succeed.
- An enrolled host with an unusable sensor says "Password", not "Touch ID". With a token enrolled, shut the lid on an external display (or fail Touch ID until it locks out) and open the About pane: "Settings changes" must read "Password — Touch ID is unavailable right now…the enrollment is intact", and a settings save must complete through the sudo prompt. Restore the sensor, re-enter the pane, and it must read "Touch ID (control token enrolled)" again. The row is evaluated on appearance, so navigate away and back rather than watching it in place.
- An entitlement does not silently brick the app. If anyone adds
keychain-access-groupstobuild-app.sh'scodesigncall, the built app is SIGKILLed at launch rather than gaining keychain access — andcodesign --verifypasses on such a binary, so the signature checks do not catch it. The release workflow now execs the installed app and fails on a137, so this is enforced at release; run it by hand after any local change to thatcodesignline, since nothing checks a dev build.
Per OS, privileged:
- Install.
dezhban installregisters the service — verify withlaunchctl list | grep dezhban,systemctl status dezhban, orsc query dezhban. - Start + survive reboot.
dezhban start→ enforcement active; reboot → the service comes back up on its own. - Stop tears down.
dezhban stop→ the run loop'sCleanup()fires, all rules are removed, connectivity is fine. - Uninstall.
dezhban uninstall→ fully removed. - Crash recovery. Kill the service process while blocked → restart-on-failure brings it back and it re-enforces.
-
restartapplies the restart-required keys (most keys apply live — see the section below), andstartandstopare idempotent. - A second
runrefuses. With the service running,sudo dezhban run(with or without--no-daemon) in a second terminal refuses immediately with "another dezhban is already running", and the first daemon's enforcement is undisturbed — no duplicate rules, no double-Apply.kill -9the first daemon, then start a secondrun: it succeeds (the OS released the lock with the process), confirming a killed daemon never wedges the next start.
Unprivileged, but they need real machine state CI has none of — a service manager, a reboot, and a VPN that has actually connected.
- Boot service, honestly reported without root. With the service
installed and running,
dezhban doctoras a normal user reports boot service: registered to start at boot, and enforcing now. This is the regression that matters: the check reads the unit file precisely because an unprivilegedlaunchctlquery cannot see the system domain and would report a live daemon as not installed. - Boot service, absent.
sudo dezhban uninstall→ the check warns and offersdezhban install. If a daemon is still running by hand, it also says so rather than reading as "the guard is off". - Not at boot. Edit
RunAtLoadto<false/>in/Library/LaunchDaemons/dezhban.plist(Linux:systemctl disable dezhban) → the check warns that every reboot comes up unguarded. Reinstall to restore. - Arm at boot, precondition met. After a VPN has been up once,
<state dir>/armed.jsonhastunnelEverUp: true, the check reports the first/last times, and a reboot arms the guard before the VPN connects. - Arm at boot, precondition missing. Remove
armed.json→ the check warns that no tunnel has been observed, and a reboot opens into standby. Connect the VPN once → the file returns and the check goes green. - Arm at boot, record corrupt. Write
{intoarmed.json→ the check warns with the parse error and dezhban still starts (a corrupt record is "never armed", never a crash). - Learned endpoints, healthy. After a normal drop and redial, the check reports addresses retained and a drop that can redial without a window.
- Learned endpoints, aged out. Set
vpn.advanced.learnedEndpointTTL=1s, wait, re-run → the check warns they aged out and offers the retention knob, not the rotation advice. - Learned endpoints, rotating. On a rotating-pool VPN (NordVPN, ProtonVPN), reconnect until the store fills → the check reports rotation and leads with the hostname fix.
The ledger is unit-tested in internal/redial against injected instants, so
what needs a real host is the wiring: a real tunnel dropping, real timers, and
both surfaces saying the same thing about it. See
ADR-0009.
- Healthy drop, full window. With the tunnel up longer than
vpn.advanced.redialMinUptime, disconnect the VPN → a window opens for the fullvpn.redialWindow, the log saysreason=full, and reconnecting snaps it shut early. - Early close is nearly free. After that reconnect, drop and redial
quickly several times.
status --jsonmust never showstate.redial, and the guard must keep granting windows — the budget measures exposure taken, so fast successful redials barely touch it. If a handful of successful redials exhaust the budget, credit-on-close is broken. - Fast drops shorten, they do not suppress. Force reconnects faster than
redialMinUptimewith no good exit in between (a deliberately wrong server works). Each drop must still get a window, each shorter than the last (reason=backoff,grantedfalling), with a growing cooldown. A drop that gets NO window at the first fast reconnect is the pre-ADR-0009 behaviour returning. - A recovery clears the cooldown. Immediately after one of those fast
drops — while the cooldown is still running — let the tunnel come back
properly and stay up past
redialMinUptime(or long enough for the exit to be confirmed), then drop it again. That drop must get a full-length window, notreason=cooldown. A refusal here is the failure that pushed recovering links ontodezhban switch: the retry would eventually re-ask, but not before the whole remaining cooldown a recovered link never earned. - Exhaustion holds, and says so. Keep flapping until the log reads
redial budget spent. Traffic must stay cut,statusmust read "Your VPN has dropped often enough to use up its redial budget…" with a real time after "dezhban tries again at", and the menubar app must show the same sentence — it rendersdisplay.detail, so a difference means something is composing prose that shouldn't. - The refusal re-decides itself. From that exhausted state, leave the
tunnel down and untouched — do not reconnect, do not run anything. At
the
nextEligiblethe refusal named, a window must open on its own (REDIAL WINDOW OPENin the log,state.switch.trigger=auto). This is the whole point of publishing an instant: before the retry timer existed, nothing acted at that time and the host stayed cut until someone randezhban switch. Verify with the VPN client stopped, so no reconnection can be confused for the cause. - One window per drop, even with the retry. Let that window expire with the tunnel still down. Nothing may open a second one, however long you wait and however much budget has refilled — the next window requires a new drop. A repeating window here is the retry re-arming after a grant.
- Hold suppresses the re-decision. Reach an exhausted refusal again, then
run
dezhban holdwhile the tunnel is still down. AtnextEligibleno window may open (redial retry skippedin the log). Then reconnect and drop: that drop must still be covered by hold — the retry honours the flag but does not spend it. - Cancelling hold gives the re-decision back. From that suppressed state
— retry already skipped, tunnel still down — run
dezhban hold --cancelonce the budget has refilled. A window must open immediately, without waiting for another drop. Nothing opening is the failure this check exists for: it means the drop is stranded until an edge that cannot arrive, andstatuswill be claiming dezhban re-checks on its own while it does not. Cancel beforenextEligibleinstead and nothing should open early — the original timer is still running and still governs. - The budget refills. Wait out
vpn.advanced.redialBudgetWindowwith the tunnel down, then drop again → a window opens. It must open no later than thenextEligiblethe refusal named. - Hold the line spends nothing.
dezhban hold, then drop → no window, andstatus --jsonshows nostate.redial(hold suppresses ahead of the ledger, so nothing was refused and nothing was charged). Reconnect, drop again → a full-length window, proving the budget was untouched. -
vpn.redialWindow: "0"still removes trigger 2 entirely, budget irrelevant; the manual switch window andpausestill work. -
redialBudget: "0"is refused, not normalised.sudo dezhban config set vpn.advanced.redialBudget=0must exit non-zero and leave the file unchanged — a limit has no "off", and silently storing2mfor a typed0is the failure this project treats as worst. - Live reload lands on the next drop. With the daemon running, lower
redialBudget, confirmSaved and appliedlists it, then flap until refusal — it must refuse against the NEW number without a restart.
macOS only, privileged (dezhban upgrade download/apply). See
upgrade.md for the full design.
- Tunnel down.
dezhban upgrade checkwith the tunnel down fails cleanly and opens nothing — it inherits the guard's tunnel-only routing rather than getting its own firewall pass. - Deferred activation during FULL BLOCK. With the guard in FULL
BLOCK,
dezhban upgrade applyinstalls the payload, refuses to activate, and leaves the old daemon enforcing normally. - Deferred activation while the guard holds a downed tunnel. With a
healthy guard, disconnect the VPN and wait for the redial window to
expire, so
statusreads "VPN down — traffic cut" at postureguard.dezhban upgrade applymust install the payload and REFUSE to activate, naming the downed tunnel — the posture string isguard, but the rules about to be torn down are the only thing cutting egress. Then reconnect the VPN and retry: the same command must now activate. The refusal is the half that cannot be caught in CI, since it needs a real tunnel to drop. - A deferred stash is NOT cleared before activation. From the state
above (payload applied, activation refused, stash present), run
upgrade applyagain WITHOUT restarting first. It must refuse with the "applied but NOT yet activated" message and leave the stash intact — the daemon is still running the stashed version, so that stash is the only copy of it. Classifying against the on-disk binary here (which already reads as the new version) would delete it; this step is the on-host check for that. - The deferred stash then resolves itself. Now
sudo dezhban restartto activate, confirm the new version is running withdezhban status(the daemon's own snapshot — notdezhban version, which reports the binary you invoked), then runupgrade applyagain for a DIFFERENT release — it should clear the now-obsolete stash automatically instead of refusing (see docs/upgrade.md, "If the restart doesn't come back healthy"). - An unreachable daemon refuses rather than guesses. With a stash
present and the daemon stopped (
sudo dezhban stop),upgrade applyrefuses with the "could not be compared against the running version" message rather than clearing anything. - Rollback. Force the new version to never publish a healthy
snapshot (e.g. stop the daemon right after the restart) →
upgrade applyrestores the previous binary/app and restarts back into it within ~30s. - Config and learned state survive.
/etc/dezhban/dezhban.jsonand/var/db/dezhban/learned.jsonare byte-identical before and after a full upgrade. - The upgraded app launches. After
upgrade applyactivates, confirm/Applications/Dezhban.appopens normally (AppActions.relaunch()'sopensucceeds) — proves the ad-hoc signature survived packaging into the.pkgand reinstall, the same invariant release.yml's smoke test now asserts withcodesign --verify.
- A fresh
dezhban setupon macOS produces an autodetect + auto-discovery config with zero concrete interface names, and offers to install and start the service. - Re-running it on a configured host seeds every question with what the
config already says, and pressing Enter through the whole wizard writes a
config identical to the one you started from (
dezhban config showbefore and after).internal/setuppins this, but only the real run proves the forms are bound to the same answers. - Re-running it without naming profile files keeps the profiles already
imported (
dezhban vpn list). - Two steps. Step 1 is countries. Step 2 opens with "Use automatic VPN detection?". On macOS, leaving it ticked ends the wizard there, and unticking it asks for tunnel interfaces, self-hosted config files, and endpoints as a follow-up prompt rather than on the same screen. Off macOS the endpoint question is ungated — there is no live discovery to find a server — so it rides on that first prompt beside the tickbox, and unticking brings only tunnel interfaces and config files.
- A re-run on a pinned config keeps its pins. With
vpn.tunnelInterfacesset, re-run and press Enter through everything: automatic detection must arrive already unticked, anddezhban config showmust still list the same interfaces. This is what replaced the old "Configure your VPN now?" escape, so it is the check that matters most in this section. Run it twice: once with the VPN up and once with it down. Detection only sees tunnels that are up, so the down run is the one where the pinned interface has to appear in the pick list — and stay ticked — on its own. - A re-run under automatic detection keeps configured endpoints. With
vpn.endpointsset and no pinned interfaces, re-run, leave automatic detection ticked, finish: the endpoints are unchanged. The question was never asked, so nothing may have been written. - Off macOS the endpoint question always appears. On Linux or Windows, leave automatic detection ticked — you must still be asked for an endpoint, because there is no live discovery to find one.
-
dezhban setup --questions --jsonruns with no TTY, no root, and no config file present, and lists the same questions the wizard asks.
- With no VPN configured and
defaults delete com.behnam-rk.dezhban.app dezhban.firstRunCompleted, launching the app opens the window and the wizard. With a VPN already configured from the CLI, it does not — the questions were already answered. - The questions, their order, and the gating match
dezhban setuprun in a terminal on the same host. Unticking "Use automatic VPN detection?" reveals the same three manual fields in both — in the app they appear on the same screen, without paging forward. - Two steps, labelled as two. The step counter reads "Step 1 of 2" and "Step 2 of 2"; unticking automatic detection must not add a third.
- Saving writes through one
config set(one password prompt, or none with a token enrolled) and the values land indezhban config show. Choosing automatic detection leavesvpn.tunnelInterfacesempty. - Cancelling with "Not now" writes nothing and offers the wizard again next launch.
- Naming VPN config files imports them as profiles (
dezhban vpn list); cancelling that second prompt leaves the config saved and only the import undone. - Settings → "Run Setup Again…" reopens it seeded with current values.
Build and launch:
task gui:build && open dist/Dezhban.app-
Menubar is a glance and the time-critical set only. The dropdown shows exactly: one status line, Open Dezhban… (⌘O), the switch/pause item with its live countdown, hold the line, Quit. No Block or Unblock — those live in the window's Overview.
-
Panic is behind ⌥. Holding Option swaps "Open Dezhban…" for "Panic — force unblock…"; ⌘⌥O fires it; releasing Option restores the open item. It still works with the daemon stopped and with the main window unable to open.
-
Window opening. "Open Dezhban…" and a Dock-icon click both open/focus the main window; closing the window (⌘W) leaves the app and icon running. Both work in every "Open minimized" mode — the preference governs the launch only and must never make the window unreachable.
-
"Open minimized" honours the setting (ADR-0014). With "Open this app at login" on, for each mode: Only at login (the default) → log out and back in, no window; then launch from Finder, window opens. Always → no window either way. Never → window both ways. The marker is what makes this work, so also confirm the login launch carries it:
ps -o args= -p "$(pgrep -x DezhbanMenu)"ends in--backgroundafter a login launch and does not after a Finder launch. -
Login-item migration is one-way and never opts you in. On an install that predates the agent: with login-at-launch on, launch once, then confirm
SMAppService.mainAppis no longer registered while the agent is (launchctl print gui/$UID/com.behnam-rk.dezhban.app.loginsucceeds) and the Settings toggle still reads on. Repeat with login-at-launch off: it must still be off, and the agent must not be registered. -
State restoration cannot reopen the window. With the window open and "Close windows when quitting an application" unchecked in System Settings → Desktop & Dock, quit and relaunch in a mode that should open no window — it must stay closed.
-
Registering the login item does not leave two apps running. With the app up, switch Settings → "Open this app at login" off then on. The agent's
RunAtLoadexecs a second copy the moment it registers, and launchd does not dedupe the way LaunchServices did, so this is the check that the session lock works: exactly one menubar item and one Dock tile afterwards, andpgrep -x DezhbanMenu | wc -lis 1. Repeat immediately after an upgrade that runs the migration. -
Launching a fully-started app again just reopens its window. With the app already running from a
--backgroundlogin launch, launch it from Finder. LaunchServices will not start a second copy of a running bundle, so this never reaches the session lock — it isapplicationShouldHandleReopen, which opens the window in every "Open minimized" mode, on purpose: the preference governs the launch, and must never make the window unreachable. Do not expect "Always" to suppress it here; the hand-off path is exercised by the startup-race check below, which is the only way to reach it. -
The first logout after upgrading may open the window once — and only once. Expected, not a regression: ADR-0014 records why
NSApp.disableRelaunchOnLogin()cannot cover the logout that happened before the new build ever ran. Upgrade, log out, log back in: a window here is acceptable. Log out and back in a second time — there must be none, andpgrep -x DezhbanMenu | wc -lmust be 1. -
"Reopen windows when logging back in" does not start a second, unmarked copy. Check that box in System Settings → Desktop & Dock, leave the app running, log out and back in. Exactly one copy must be running and it must have come from the login agent, not the resume:
ps -o args= -p "$(pgrep -x DezhbanMenu)"ends in--background, and under the default "Only at login" there is no window. This isNSApp.disableRelaunchOnLogin(); without it, LaunchServices relaunches the app with no arguments and races the agent for the lock. -
A refusal's explanation survives switching away and back — and expires when it stops being true. Run
dist/Dezhban.app, click "Open this app at login" — it refuses and says why. Click another app and click back: the explanation must still be there. Only messages about a moment that has passed ("macOS is holding this for your approval") may be cleared by that refresh; a switch that snapped back with the reason erased is indistinguishable from a bug. Then clear the condition and return — once the switch moves, the stale refusal must go with it rather than sit there contradicting it. -
The login toggle's result has its own line. Start the service toggle ("Start the guard at boot"), and while its privileged sequence is running flip "Open this app at login". Both messages must be readable at once — the install's progress on the pane's status line, the login result underneath the toggle — and neither may erase the other.
-
Two hand-offs in quick succession both open the window. Only reachable while the incumbent is still starting — once it is up, LaunchServices reopens rather than launching a duplicate, so there is no hand-off to debounce. Log out and back in, then double-click the app twice in quick succession as early as you can, closing the window (⌘W) in between. Both must open. Best-effort by nature: the deterministic coverage is
HandoffRequestTests.anOverlappingClaimerIsToldItLostandtheClaimCarriesThePostedToken, since it is the token — not any timing rule — that tells the two signals for one request from two requests. -
A hand-off that arrives before the app is observing still works. The race the
HandoffRequestfile exists for: log out and back in and double-click the app in/Applicationsas early as you can, while it is still starting from the login agent. The window must open — within about half a second if the notification missed it, from the bounded backstop that runs for the first few seconds. This must hold on a slow login too: the request is honoured however long the incumbent took to finish starting, because the file can only have been written after it took the lock. It must open once: no second activation a moment later, and a window you close right after must stay closed. Then confirm no.handofffile is left in~/Library/Application Support/com.behnam-rk.dezhban.app/. -
An app filed into a subfolder of /Applications still migrates. Move
Dezhban.appinto/Applications/Utilities/, launch it on a pre-agent install with login-at-launch on: it must migrate. Anywhere under an Applications directory counts as a place the app will stay; comparing only the immediate parent left that user's legacy item running with no marker permanently, reported nowhere. -
A copy run from outside /Applications cannot claim or release the login item. Run
dist/Dezhban.appand switch "Open this app at login" on: it must refuse, with the line naming where it is running from, andlaunchctl print gui/$UID/com.behnam-rk.dezhban.app.loginmust still fail. Only the registering bundle can ever retract a registration, so a dev build that claimed it would leave an orphan nothing can remove oncedistis rebuilt.Then the other direction, which matters more because it is silent: with the *installed* copy's login item **on**, run `dist/Dezhban.app` — the switch reads ON, since the agent is registered under a label every copy shares — and click it **off**. It must refuse, and `launchctl print gui/$UID/com.behnam-rk.dezhban.app.login` must still succeed afterwards. A dev build retracting the installed app's registration is bad enough; doing it without even recording the user's "off" is worse. -
A copy run from outside /Applications does not migrate the login item. Unzip
Dezhban-macos.app.zipto~/Downloadson a Mac with a pre-agent install and login-at-launch on, run it once, quit. The legacy login item must still be there anddefaults read com.behnam-rk.dezhban.app dezhban.loginItemMigratedToAgentmust be absent — otherwise the account is marked done with the agent pointing into~/Downloads. Then run the copy in/Applications: that one must migrate. -
A login item the user turned off in System Settings stays off across an upgrade — and is cleared, not left dormant. With a pre-agent build, switch Dezhban off under System Settings → General → Login Items (this leaves
mainAppat.requiresApproval, not unregistered), then upgrade and launch. Login-at-launch must still be off and no agent registered — and the entry must be gone from Login Items. A dormant.requiresApprovalitem is live: re-approving it there would start the app at login with no marker, and the migration will not run again to fix it. -
Two quick clicks on the login switch settle on the second one. Click it off then immediately on (and the reverse). The final switch state must match the last click and the actual registration —
launchctl print gui/$UID/com.behnam-rk.dezhban.app.loginagreeing with what the switch shows. Then reopen the pane to confirm it still agrees. -
Switching login-at-launch off from a login-started session. Log out and back in so the agent starts the app, then switch Settings → "Open this app at login" off.
SMAppService.unregister()unloads the launchd job and launchd terminates a loaded job's process — which here is the app — so watch for the app quitting instead of showing the status line. Recorded as an open risk in ADR-0014; if it reproduces, note whether the registration was still retracted. -
The migration retries a failed agent registration. Hard to provoke honestly: with a pre-agent install and login-at-launch on, make
register()fail once (an unsigned bundle is the easiest way), launch, and confirm the log says it will retry. Then fix the bundle and launch again — the agent must register.defaults read com.behnam-rk.dezhban.app dezhban.loginItemMigratedToAgentmust be absent or 0 between the two. -
An awaiting-approval registration is explained on a fresh pane open. With the agent registered but switched off under System Settings → General → Login Items, quit Dezhban and reopen Settings without touching the switch. It reads ON (an awaiting-approval registration counts as registered), so the line explaining that must be there unprompted — nothing else can express the difference.
-
The uninstall errand does not cry wolf. On an install where login-at-launch was never switched on, run
Dezhban.app/Contents/MacOS/DezhbanMenu --unregister-login-item; echo $?— it must be 0.SMAppServicereports.notFoundfor an agent that was never registered, not only for a plist it cannot resolve, so anything treating that status as "cannot tell" warns on every ordinary uninstall. -
The approval prompt's own guidance survives it. Turn the login item off in System Settings, then switch Dezhban's "Open this app at login" on: the line says macOS is holding it for your approval. Click away and back without approving — the line must still be there, because the switch reads ON either way and cannot show the difference. Then approve it and return: the line must go.
-
An awaiting-approval registration can still be switched off. Turn the login item off in System Settings (not in Dezhban), then switch Dezhban's "Open this app at login" on: the status line must say macOS is holding it for approval, and the switch must then turn off again on the next click rather than re-registering.
launchctl print gui/$UID/com.behnam-rk.dezhban.app.loginmust fail afterwards. -
The dev build is not deduped against the installed one. With
/Applications/Dezhban.apprunning,task gui:build && open dist/Dezhban.app. Both must run — the lock is keyed on the bundle path precisely so every other manual check on this list tests the build you just made rather than silently testing the installed copy. -
The login item is attributable. System Settings → General → Login Items shows the entry as Dezhban, not as
com.behnam-rk.dezhban.app.login(that isAssociatedBundleIdentifiersdoing its job — this is the switch a user reaches for to stop the app starting at login, and it is useless if nobody can tell what it governs). -
No
.claiming-*files accumulate. After exercising hand-offs, and after a forced quit during one,ls -a ~/Library/Application Support/com.behnam-rk.dezhban.app/must show no.claiming-*leftovers — a process killed between the rename and the read leaves one, and only the next session owner's sweep removes it. -
Uninstall handles two installs. Put a copy in
/Applicationsand one in~/Applications, let the second register the login agent, then run the uninstaller. Both bundles must be gone, the agent registration must be retracted (launchctl print gui/$UID/com.behnam-rk.dezhban.app.loginfails, still after a reboot), and no clean-removal message may print over a survivor. Only the registering bundle can retract, so each copy's errand has to run from that copy while it still exists. -
Uninstall finds the app where it actually is. Move
Dezhban.appinto/Applications/Utilities/, let it register the login agent, then run the uninstaller. It must locate the bundle there, retract the agent, delete it, and print no "app bundle was already gone" warning — the app is allowed to register from anywhere under an Applications directory, so the uninstaller has to look in the same places. -
Uninstall with nobody logged in says so. From an ssh session on a Mac sitting at the login window, run the uninstaller. It must finish and warn that the per-user leftovers could not be removed — every step of that teardown needs the user's own launchd session, and reporting a clean removal would hide both a surviving login item and the migration flag that makes a later reinstall skip the migration.
-
Uninstall over an already-trashed app says so. Drag
/Applications/Dezhban.appto the Trash, then run the uninstaller. It must finish and print the warning naming System Settings — only the app can retract its own registration, so with the bundle gone the entry cannot be removed by anything and reporting a clean uninstall would hide it. -
Uninstall retracts the registration, not just the running job. With login-at-launch on, run
sudo sh /usr/local/share/dezhban/uninstall.sh, then confirmlaunchctl print gui/$UID/com.behnam-rk.dezhban.app.loginfails and the System Settings → General → Login Items entry is gone, and that it is still gone after a reboot. A per-user launchd agent does not go away with its bundle the way a LaunchServices login item did, andlaunchctl bootoutalone only unloads it for the current boot — the reboot is what distinguishes a real retraction (the--unregister-login-itemerrand the script runs as the console user) from an unload that comes back.defaults read com.behnam-rk.dezhban.appmust also fail afterwards: a surviving migration flag means a later install is never moved onto the agent. And if macOS does refuse the retraction, the script must say so — the closing warning naming System Settings, not a clean "files deleted". -
The login agent registers from an ad-hoc-signed build. Only reachable on a real install:
build-app.shsigns withcodesign -s -, andSMAppService.agentregistration goes through launchd's validation of the bundle, whichSMAppService.mainAppnever needed. If ad-hoc does not satisfy it, login-at-launch fails as a silent.notFound/.requiresApprovalstatus rather than a crash — so checklaunchctl print gui/$UID/com.behnam-rk.dezhban.app.loginon a build installed the way users get it (.pkgor the app zip), not ondist/Dezhban.apprun in place. -
Posture tracking. Drive the daemon with
--simulate-country IR/USand confirm the menu bar icon and the Dock tile flip red/teal and the window's Overview updates within ~1 s. -
Auto-arm (
vpn.autoArm: true). Start the daemon with the VPN off → posturestandby, egress open, gray icon. Connect the VPN →guardwithin a few seconds ("AUTO-ARMED" in the log) and a "Guard armed" notification. Disconnect → guard HOLDS (red blocked icon, egress cut). Unblock (menubar or Overview) → back tostandby, egress open. Redial → arms again. -
Essential notifications. With notifications on (Settings pane), the armed/blocked/warning/standby/stopped transitions each notify once; no notification at app launch or on routine country/endpoint updates.
-
Staleness. Kill the daemon → the icon goes gray after the 90 s staleness window, and Overview switches to the guided "Stopped" state.
Destructive and one-way — run these on a machine you are willing to reinstall on. See ADR-0015.
- No key removes it. Settings → Remove Dezhban…, press Return: nothing happens — there is deliberately no default button. Press Escape: the alert dismisses with nothing removed, and the guard is still enforcing.
- The per-user half actually goes. Enroll Touch ID and enable "open at
login" first, then remove. After the app quits:
defaults read com.behnam-rk.dezhban.appfails with "domain does not exist";security find-generic-password -s sh.dezhban.menureportsSecKeychainSearchCopyNext: The specified item could not be found— one run is enough, and it proves both items are gone, becausefindreturns the first match under the service and there is none; Dezhban no longer appears in System Settings › General › Login Items. The last of those is retracted by the script's--unregister-login-itemerrand, not by the app — the app must NOT callunregisterbefore the Terminal hand-off, because launchd can kill it for that call in a login-started session. - The dialog reads correctly. No runs of stray spaces mid-sentence (a soft-wrapped Swift multiline literal keeps its indentation), and the notification sentence says you must turn it off yourself — the purge cannot.
- A login item switched off in System Settings still goes. Enable "open
at login", untick Dezhban in System Settings › General › Login Items
(status becomes
.requiresApproval, still a live registration), then remove with the uninstaller renamed away and dezhban taken down properly first (sudo dezhban panic && sudo dezhban stop && sudo dezhban uninstall, then remove the CLI) — the app retracts the login item only when nothing root-owned is left, so leaving either artefact in place correctly takes the still-installed branch and retracts nothing, and deleting the plist by hand instead leaves the job loaded and the rules enforcing where no filesystem check can see them. The Login Items row is gone afterwards, not left pointing at a deleted bundle. - The teardown is visible. Terminal opens,
sudoprompts, and the transcript showspanicremoving the rules BEFORE anything is deleted. Confirm the network works throughout — a half-removed kill switch that leaves a block-all rule loaded is the one outcome this must never produce. - KEEP_CONFIG. With the checkbox ticked,
/etc/dezhban/dezhban.jsonsurvives; without it,/etc/dezhbanis gone. - Reinstall looks fresh. Reinstall, launch the app: the first-run wizard opens. This is the bug the purge exists to fix.
- A missing uninstaller on a live install does not claim dezhban is gone.
Rename
/usr/local/share/dezhban/uninstall.sh— the state acurl | shinstall leaves when the uninstaller fetch fails, and the state a machine bootstrapped byscripts/install-local.shis in from the start — then remove. The app must say dezhban is still installed and still enforcing, must not offer to let you delete Dezhban.app, must not retract the login item (the app is the only status surface left for a running guard), and must not quit. Confirm afterwards that the rules are still loaded anddezhban statusstill answers. - A missing uninstaller with nothing else installed reports the truth.
From the state above, run
sudo dezhban panic && sudo dezhban stop && sudo dezhban uninstallFIRST — deleting the plist by hand leaves the job loaded and the rules enforcing, which no filesystem check can see, so a tester who skips this manufactures the exact "says gone, still enforcing" state the branch above exists to prevent and records it as a pass. Then remove the CLI, relaunch, and remove: now the app says the root half is already gone, does not print a command naming a file it just failed to find, retracts the login item as the alert is dismissed, sweeps this account's session lock and preference domains a second time, and quits. - Terminal refusing degrades honestly. Deny Dezhban under System
Settings › Privacy & Security › Automation, then remove with the
uninstaller in place: the app says the keychain key and settings are gone
but dezhban is still installed and still enforcing, prints the exact
sudocommand, names the Automation setting, and does not quit. - CLI missing does not disable the button.
sudo rm /usr/local/bin/dezhban, relaunch, open Settings: Remove Dezhban… is still enabled. It is the only way left to clear this account's keychain item. - The script's own per-user pass. From a second account over SSH, run
sudo sh /usr/local/share/dezhban/uninstall.shdirectly: both of that account's preference domains are deleted (checkdefaults read com.dezhban.DezhbanMenutoo — the legacy domain is the one that used to survive), and the output names the keychain item and the login item it did not touch. A third account's settings are untouched — though its session lock and saved window state are swept, which is the line ADR-0015 draws and the glossary states.
-
The action row explains itself before the click. Titles are short (Block / Unblock / Switch VPN… / Pause / Guard down). Panic… is not part of this row — it sits below with its own fixed caption, so hovering it changes nothing, which is correct rather than a failure. Move the pointer across the row: the caption line beneath it changes to that control's sentence — in particular, hovering Pause must say it uses your real ISP IP, which is the warning its old title carried — and the whole sentence must be readable, including the password expectation at its tail, which is what a single truncated line used to cut. Narrow the window until the action row wraps and check it again there: two reserved lines were enough at a comfortable width and put the ellipsis back on the password clause at a small one, which is why the caption reserves three. Moving off the row leaves a prompt ("Point at a button to see what it does."), not the posture headline — the status hero already shows that a few lines above, and the caption used to repeat it verbatim. A disabled Block or Unblock says why it is disabled rather than describing the action it will not perform.
**Unblock names what it will actually release.** The rule is the tunnel first, the posture second, because the daemon's unblock handler branches on `AutoArm && !tunnelUp && !standby` without looking at why egress was cut. So check three states, not two: (a) guard holding a downed tunnel — pull the VPN; (b) `dezhban block` with the VPN **off**, which is a full block over a downed tunnel; (c) `dezhban block` with the VPN **up**. (a) and (b) must both warn that enforcement stops and traffic uses your **real IP** until the VPN reconnects (`vpn.autoArm` is on by default, so both drop the daemon to STANDBY); only (c) may say the block is lifted and the guard re-blocks. It may not claim the block was manual or automatic in any of them: `postureName` derives `full-block` from `blocked` alone, so both arrive as the same posture string and nothing on the wire tells them apart. And read the whole caption at a narrow width — these sentences share the three-line reservation with the row's longest existing hint, so an overlong one truncates the password clause off the tail. Tab through the row with Full Keyboard Access on and confirm focus drives the caption too, **including with the pointer left resting on a different button** — focus is meant to supersede a parked pointer. Then move the pointer onto a control (the same one or another) and confirm it takes over again: re-entering is what hands it back, deliberately, since jiggling inside the control you are already on aims at nothing new. Then, still with the pointer parked on a button, Tab *out* of the row altogether. The caption must go back to describing the button under the pointer — not to the resting prompt. Focus outranks a parked pointer while it is in the row; it does not erase where the pointer is. The line must never go blank or change height either, which would reflow the row under the pointer. Two more, both about a caption outliving what it described, and both needing the pointer to stay put — so trigger them from a terminal that already has focus, with the command pre-typed. Reaching for the menubar moves the pointer off the control, which fires a hover-exit and clears the state the step is trying to observe. With the pointer parked on **Pause**, press Return on a waiting `sudo dezhban switch --no-wait`: the button under the pointer becomes **Cancel**, and the caption must stop describing Pause without waiting for the mouse to move. Leave the pointer there through the next few countdown ticks — the caption must stay on Cancel, and must not be handed back to the pointer if you had tabbed elsewhere first, since retitling re-establishes the tracking area under a stationary mouse. Run that swap **once more with keyboard focus on Block first**, pointer still parked over Pause. Cancel replaces Pause underneath it, which is a *new* control arriving rather than the same one retitling — but the hand has not moved since Tab, so this is not the user aiming either: the caption must stay on **Block**, where the focus ring and the Space key are. Then move the mouse onto any control and confirm the pointer takes it back immediately. The same applies when an enforcement-error banner appears above the row and shifts a different button under a stationary pointer. Do that one twice more, because the reference point is *where the mouse was when Tab moved the focus*, not where it was at the last hover event — and the two differ in both directions. First **nudge the pointer a few points inside Pause before tabbing** (no hover event fires, so a reading taken at the last boundary would be stale): the caption must still stay on Block when Cancel arrives. Then, with focus on Block, **flick the mouse quickly from one button to the next** rather than easing across: the exit and the enter can be dispatched from a single mouse-moved event, and the pointer must still take the caption back. And with focus on **Pause**, open a window the same way: focus lands on the replacement control, and the caption must describe **Cancel** rather than keeping Pause's sentence — "uses your real ISP IP" under a button that ends a window is the opposite of what it does. Then, with the pointer parked on **Block**, run a pre-typed action that ends in a `refreshServiceState()` — any Overview action will do — with the control socket having gone away underneath (`control.enabled=false` plus a restart beforehand). The caption's password clause must follow the tooltip's rather than keeping the answer it was given at hover-enter. It has to be an action rather than simply waiting: the 1-second timer polls the state file and repaints only, so `controlIsReachable` changes at launch, when either surface opens, and after an action sequence — not on a tick. Do not reach for either by stopping the daemon: `state.isLive` goes false, Overview renders its guided "stopped" layout, and the action row and its caption line are gone before any of this could be observed. `routineHint` keys on `controlIsReachable`, which is the socket, not the posture. -
VoiceOver still hears what each button does. With VoiceOver on, move through the action row: each control announces its short title and its consequence as a hint — Cancel in particular must say whether it closes the automatic redial window or one you opened, since the titles no longer carry that and the caption line is hidden from VoiceOver to avoid reading it twice. Panic… below the row too — it is not in the action row and has no caption line, so its own hint is the only thing that says it force-unblocks; its visible sentence beside it is hidden from VoiceOver for the same read-it-twice reason. Hovering Panic… must also produce a tooltip.
-
The degraded states keep their long panic title. With the CLI missing or the service not installed, the panic button still reads "Panic — force unblock…" — there is no caption line there to carry the explanation.
-
Routine ops are passwordless with a live daemon. Block/Unblock (Overview only — they are deliberately not in the menubar) and the switch window (both surfaces) complete over the control socket with no prompt; the switch countdown ticks in both surfaces and matches.
-
Pause and Resume, from both surfaces. Pause opens with no password (
control.allowPauseOpsdefault true); Overview shows "Resume (m:ss left)" and the menubar "Resume now (m:ss left)" in place of the switch-window Cancel item, and the countdown agrees between the two. Resuming early re-arms the guard immediately. Letting a pause expire re-arms it with no action needed. Withvpn.pauseMax: "0", Pause is disabled in both surfaces with a reason ("vpn.pauseMax is "0""), not just a silent no-op. -
Profile picker. With
configs/dezhban.profiles.json, Overview's details grid lists every configured profile and marks the one that matched ((active)), matchingdezhban vpn list; "Switch VPN…" becomes a menu with "Any known VPN" plus one item per profile, and picking a profile passes--name <profile>(dezhban vpn listshows the learned endpoint attributed to it afterward). With no profiles configured, it's a plain button, not a one-item menu. -
Privileged actions. Start/Stop raise a native admin prompt (Touch ID or password), run, and the state reflects the result.
-
Menubar panic works without the window. From a fresh launch (main window never opened): Panic shows a confirmation, confirming removes the rules and the transcript appears in an alert; cancelling does nothing.
-
Window panic routes its transcript to the Logs pane and navigates there.
-
Failures are visible, not silent. Move the CLI binary aside (or invalidate the config), then trigger Start/Stop → the alert shows real stderr.
These need a Mac with Touch ID and pam_tid enabled in /etc/pam.d/sudo_local.
CI cannot run any of them — a biometric prompt has no headless form — and the
failure they guard against is precisely the one that made every Touch ID user
end up typing a password.
- A Touch ID miss offers a password, not a dead end. Trigger a privileged
action (Start/Stop), then deliberately fail the biometric read three times
(a non-enrolled finger works). Expect the bundled askpass dialog asking for
the administrator password, and the action completing after a correct one.
Before the
SUDO_ASKPASSfix, the first miss dropped straight to a password-only Authorization Services dialog. - Cancelling means cancelled. Dismiss the Touch ID prompt, then dismiss the password dialog. The action must report failure and change nothing — not silently retry, and not leave a half-applied state.
- Clamshell / no sensor. With the lid closed on an external display, a privileged action still reaches a usable password prompt.
- The timestamp cache still works. Two privileged actions in quick succession prompt once, not twice.
- No
pam_tid, no regression. Comment outpam_tidin/etc/pam.d/sudo_local→ privileged actions fall back to the system Authorization Services dialog exactly as before, not to the askpass dialog. - The askpass helper is where it should be.
ls -l /Applications/Dezhban.app/Contents/Resources/askpass.shis present, mode0755, and inside the bundle — never a user-writable path, since sudo executes whateverSUDO_ASKPASSnames.
- CLI binary moved aside → Overview explains "dezhban CLI not found" (and the menubar status line agrees); restore it → recovers on next refresh.
- Service uninstalled → "Guard not installed" with an inline Install service… that installs + starts under one prompt and shows its transcript in Logs.
- Service installed but stopped → "Stopped" with an inline Guard up.
- Start at boot reflects whether the service is registered, flips after install/uninstall (one prompt each, uninstall confirms first), and the uninstall tears rules down before unload.
- Launch at login — the login-item checks live with the launch-marker
block earlier in this file, since they are the same mechanism; the switch
registers the agent and a correct run leaves
SMAppService.mainAppunregistered. - Guard fields seed from
dezhban config showvalues; Apply raises the restart-warning choice; "Save only" writes without restarting. - Restart dezhban… works with nothing else pending: a plain "are you
sure?" during GUARD or STANDBY, but a stronger,
.criticalwarning during FULL BLOCK or an open switch/redial/pause window that says enforcement is briefly lifted and your real IP may be exposed. Cancelling changes nothing. - Open Config File… opens the resolved config path.
- Preset picker. With a freshly-installed (default) config, Balanced
shows checked and its summary/cost text; clicking Strict shows the cost
in the confirmation, and after applying, Strict is checked and Balanced
is not. Hand-edit one key afterward (e.g.
pollInterval) and reopen the pane — no preset is checked, "Custom" shows, and the disclosure lists exactly the keys that differ from the nearest preset (matchesdezhban config preset diff). - Advanced disclosure. Collapsed by default; expanding it seeds from
dezhban config show'svpn.advancedblock, and Apply writes changes there through the same batched write as every other field (one prompt, not a second one for Advanced).
- Opening the pane never prompts. The toggle shows the right state without a biometric prompt — enrollment is checked without reading the token, and reading it is the only thing that should ever prompt.
- Turning it on asks for a password once, then reports success.
dezhban token statusin a terminal agrees that a token is enrolled. - Applying a change then costs a Touch ID tap, not a password, and the change is in force when the pane says "Saved and applied".
- Cancelling the Touch ID prompt falls back to the password path rather than failing the save — a cancelled biometric is not a refusal.
- A daemon refusal is not escalated. Set
control.allowConfigOps: falseand restart; a save reports the refusal and must NOT then show an admin password prompt that would perform it anyway. - Turning it off removes both copies. The toggle goes off,
dezhban token statusreports "not enrolled", and saves ask for a password again. - Changing your fingerprints does NOT invalidate the stored token. Add or
remove a fingerprint, then save → it still costs a Touch ID tap and still
succeeds.
.biometryCurrentSetis gone with ADR-0012 — the keychain item is ordinary and the check is the app's, so there is nothing for a fingerprint change to invalidate. Adding a finger already needs the login password, which already grantssudo. Recorded here so the loss is re-confirmed on each pass rather than rediscovered as a surprise. - On a Mac without Touch ID the toggle is disabled and explains why; settings changes keep working through the password path.
- Opening the pane with the service stopped seeds values matching
dezhban config show. - Applying a valid change raises the restart-warning modal, then: the
config setcalls land, the icon goes ⚪ across the stop/start gap, and it resolves to 🟢/🔴 for the new mode. - A change that fails cross-field validation is refused before any restart — e.g. a profile with no valid endpoint. No stop/start may happen on a config that would fail to start.
- Killing the daemon mid-restart makes the pane report failure, not success.
- One prompt per apply — not one per field.
- Rows and their statuses match a hand-run
dezhban doctor --config …; checking "Find my VPN's server" matchesdezhban doctor --discover. - A
fail/warnrow's fix text is readable and matches the fix textdezhban doctorprints in a terminal. - CLI missing → the guided "dezhban CLI not found" state, not a blank list.
- Problems reads the real log. Diagnostics → Recent problems lists the
same records as
dezhban logs --level warn --limit 100, newest first, with each record's attrs beside it in the order dezhban wrote them. - "None" is shown as the good answer. On a host with a clean log the section reads "Nothing logged as a warning or an error" in green — not an empty list, and not the "couldn't read" message.
- Rotation is covered. Force a rotation (or rename
dezhban.logtodezhban.log.1and restart), then confirmdezhban logsstill shows the archived records, oldest first. - An oversized line does not swallow the rest of the file. Append a line
longer than 4 MiB to
dezhban.log(python3 -c "print('x'*5000000)" | sudo tee -a <state dir>/logs/dezhban.log), then write a normal record after it.dezhban logsshows the records written after the long line — that is the half that used to be lost — with onelog line too long to read; skippedwhere it was, and no "part of the log could not be read" warning on stderr.--level errorhides that stand-in;--level warnshows it. Diagnostics → Recent problems shows it too, as a warning with no timestamp. In a redacted bundle'slog.txtit still readslogread.oversized=…, nothost-N=…. - The bundle collects. Export… → pick a folder → Finder reveals
dezhban-report-<stamp>.zip. Open it: README.txt, config.json, state.json, learned.json, armed.json, applied-rules.json, doctor.json, rules-preview.txt, log.txt. Anything absent is named under "Not included" in the README rather than missing silently. - The redaction actually holds. This is the check that matters — a
redactor that misses a field advertises a safety it did not deliver.
Unzip a default (redacted) bundle and grep every file for your real VPN
server address, your provider's hostname, and your public exit IP from
dezhban status. None may appear. Then confirm the structure survived:utun*names, ports,127.0.0.1, and your private subnets are still there, and the same server is the sameip-Ntoken in config.json, learned.json and rules-preview.txt. - The names go too, not just the addresses. In the same redacted bundle,
grep for your profile names (
vpn.profiles[].name,activeProfile, anytunnelHint), your macOS account name, your Mac's.localname, and the basename of any.conf/.ovpnyou imported. None may appear — each is the provider or the person stated in plain words, which no address-shaped rule can see. Confirm the structure survived:/Users/user-1/Downloads/…still reads as a Downloads folder, and the same profile is the sameprofile-Ntoken in config.json and state.json. - The interface name goes from the PROSE and the RULESET, not only from the
fields that name it. If your VPN client made its own interface
(
nordlynx,proton,gpd), grep the whole bundle for it — and make sure you look inrules-preview.txtandlog.txt, not just the JSON. A rendered pf or nft rule carries it as a bare word (pass out quick on { … },oifname { … }), doctor's tunnels and lockout checks write it into a finding, and the daemon logsdetail="… up"— none of which any shape can see. None may appear. Then confirm the other direction: everyutun*/en0/lo0is still there, or the ruleset has become unreadable for no gain. Same check for the VPN's service name as Network settings shows it. - One token, one identity. Two different servers must be two different
ip-N, and two different profiles two differentprofile-N. The README's legend is where a collision shows: a row naming the same token twice, or two rows claiming one token, means the bundle reports two things as one — which reads as a working bundle right up until someone tries to diagnose with it. A legend row rendered as a list ("2 distinct profile names → profile-1, profile-3") rather than a range is not a bug: a number is skipped whenever it would have produced a name the bundle actually carries. - Nothing dezhban ships is redacted. The other direction, and it fails
just as badly: a bundle that hides the diagnosis has thrown away the
answer and hidden no identity. In the same bundle confirm dezhban's OWN
vocabulary survived —
doctor.json's check names (config,tunnels,endpoints,lockout, …), the shipped geo-provider hostnames, the posture strings (guard,full-block,switch-window,standby), the mode names in rules-preview.txt, everyutun*/lo0, and the paths of dezhban's OWN files — the control check names the socket it probed (…/control.sock), and a path reading as…/host-Nhas had the answer replaced rather than an identity. If the check names read asprofile-N, the redactor has replaced the answer rather than the identity, and the legend is overcounting to match. - Every JSON entry still opens.
for f in *.json; do python3 -m json.tool "$f" >/dev/null || echo "$f"; doneover the unpacked bundle prints nothing. A redactor that rewrites text it does not understand can break the escaping of the file it is rewriting, and adoctor.jsonno reader can open is a diagnosis nobody gets — which looks exactly like a working bundle until someone tries to use it. - The bundle is 0600.
ls -l dezhban-report-*.zip— an--include-networkbundle must not be readable by other local accounts. - The README never leaks. Its legend reports counts and the tokens they cover — a range when they run consecutively ("23 distinct IP addresses → ip-1 … ip-23"), a list when one was skipped ("2 distinct profile names → profile-1, profile-3") — and no originals. Every token it names must be findable somewhere in the bundle.
- The opt-out is loud. With "Turn redaction off: include my real server addresses, exit IP, VPN profile names and account name" ticked, the bundle contains all of those AND says so at the top of its README; the CLI prints the same warning on stderr. The label, the warning and the README name the same set — the checkbox turns the whole redactor off, not just its network half, and that is what someone is consenting to.
- A bundle collects on a bare host. With dezhban installed but never
started,
dezhban reportstill writes a zip — the missing state files are notes, not failures.
- Applied appears without a password. With the guard up, open
Diagnostics: "Applied by dezhban — Guard" shows a timestamp and the pf
ruleset, with no prompt. Compare it against
dezhban print-rules --appliedin a terminal — same text. - It tracks the posture. Drive a block with
--simulate-country IR; the applied row becomes "Full block" and the timestamp moves. Open a switch window; it becomes "Switch window". - Teardown clears it — by every route. Check all three separately:
sudo dezhban stop(the daemon's own Cleanup),sudo dezhban panic, andsudo dezhban unblock --force. After each, re-open Diagnostics and rundezhban print-rules --applied: both must read "no ruleset recorded yet".panicis the one that matters most and the one that was broken — it is deliberately daemon-independent, so nothing else will ever clear the record. A stale ruleset shown as live over an open network is the failure this must never have. - A block applied by hand is recorded.
sudo dezhban block --guardwith no daemon running, thendezhban print-rules --applied: it shows that ruleset, not "nothing recorded". The record must not be truthful only when the daemon happened to be the one enforcing. - The kernel readback asks for a password and only reads. "Read from the
kernel…" prompts once and shows
pfctl -a dezhban -s rulesoutput. Confirm nothing changed:dezhban statusand the posture are identical before and after, and running it with the guard DOWN reports "no dezhban rules are loaded" rather than an error. - Loaded but not filtering is reported as loudly as missing. With the
guard up, disable pf itself (
sudo pfctl -d) — the rules stay loaded — then "Read from the kernel…": the pane must show an orange "dezhban's rules are loaded but are NOT filtering" row without expanding anything, andsudo dezhban print-rules --installed --jsonmust report"enforcing": falsewith awarningsentry. Re-enable withsudo pfctl -e. This is the state where every other signal reads healthy, so a warning buried inside the ruleset text would never be found. - Drift is reported, not repaired. With the guard up, flush the anchor by
hand (
sudo pfctl -a dezhban -F rules), then "Read from the kernel…": the pane must warn that dezhban applied rules the firewall no longer holds, and must offer no repair button. Then confirm the daemon's own verify tick re-applies them withinvpn.advanced.verifyIntervaland the log says so. - The previews cost nothing and need no root. As an unprivileged user
with dezhban stopped, expand each of Guard / Full block / Switch window:
each renders, and each matches
dezhban print-rules --mode <m>. - Only what is opened is rendered. Visiting Diagnostics with every
disclosure collapsed must spawn no
print-rules --modesubprocess (watch withsudo fs_usage -w -f exec | grep dezhban, or Activity Monitor). Oneprint-rules --applied --jsonis expected on every visit — that is the cheap unprivileged record the "Applied by dezhban" row is made of, and it is fetched whether or not anything is expanded. - The kernel readback does not outlive its posture. Read from the
kernel with the guard up, then force FULL BLOCK (
--simulate-country IR). The kernel row must not still be presenting the guard ruleset: it is titled with the time it was read, and pressing Run diagnostics clears it rather than leaving a stale snapshot beside fresh rows.
The pane's whole reason for existing is that it works while the guard has cut traffic, so the check that matters is the one CI cannot run: with egress gone.
- With FULL BLOCK active (or the tunnel down and no window open), open
Help: pages render fully, the sidebar and search work, and nothing is
blank or spinning. Confirm no request leaves the machine — Little Snitch,
or
tcpdump/pfctl -s stateshowing nothing new fromDezhbanMenu. - A link in a page to another bundled page moves the sidebar selection with it, so the highlighted row is the page being read.
- A link that points off the bundle (an
https://one in a doc) is refused in the pane and reported with a Copy link button — it must not navigate. - A link to a doc that is not bundled — the ADR references in Postures,
say — reports a
https://github.com/…URL, not afile:///…/Contents/…path. Every such link was a dead click reporting an internal path before the renderer rewrote them. - Pages are styled — headings, table borders, code backgrounds, and the
dark-mode palette. The bundled pages carry a
Content-Security-Policy, and afile:origin is opaque, so a CSP that is too strict would silently drophelp.cssand leave the pane readable but unstyled. Only a real WKWebView shows this; the Go tests cannot. - Built from a checkout whose
docs/was renamed under it,task gui:buildfails rather than producing an app whose Help pane is missing a page. - The ? beside a Settings field opens Help scrolled to that key's own
table row — not to the section heading it shares with dozens of other
keys, and not to the top of the reference. Spot-check one field per
section, including one under Advanced (whose rows are anchored on the
fully-qualified
vpn.advanced.*name). Its tooltip says what the button does ("Open the documentation for …"): the key's own one-line help is already on the control beside it, so repeating it here left nothing telling a pointer user that the button navigates at all. And it stays there. Watch the pane for a second after it opens: the row must remain on screen, highlighted by:target. The deep link's anchor is spent by the navigation it triggers, so the very next view update asks for the same page with no fragment — answering that by loading again scrolled the reader back to the top a fraction of a second after arriving (HelpNavigation.shouldLoad). Then click a different key's ? on the same page and confirm it still moves: the fix must not turn into "never navigate within a page again". Finally, search in the Help pane, click a heading hit, scroll away by hand, and click the same hit again — it must scroll back. An anchored request is honoured again on a repeat, including of the anchor already showing; suppressing it as "already there" made the second click do nothing. It must not be honoured twice in one breath, though: watch the pane for a flash or a half-drawn page as it opens, which is a secondloadFileURLcancelling the first —makeNSViewandupdateNSViewboth run in the turn the anchor is still pending, and only the served-anchor record tells that duplicate from a real repeat. - A CLI newer than the bundled help still lands on the section. The ?
offers the key's row first and its section second, so an app bundle predating
row ids must land on the section heading rather than the top of the page.
Reproduce by running the built app against a help bundle from before this
change, or by checking that
dezhban config schemaprints adocs:anchor that resolves as a heading on GitHub — that is the one a CLI reader follows, and row ids exist only in the app's rendered help. - Against a CLI too old to know
config schema, the ? buttons are absent rather than present and inert.
- "Show last hour" matches a hand-run
log show --last 1h --predicate 'process == "dezhban"'. "Stream live" updates live; Stop — or closing the window mid-stream — ends the child process (no orphanedlog streaminps). - About shows a version matching
dezhban versionand paths matchingdezhban config path.
These are deliberate, not oversights:
- Code signing / notarization. The
.pkgand the app are unsigned (no Apple Developer certificate);build-pkg.shcarries the signing seams. Gatekeeper needs a right-click → Open on first launch. - SMJobBless privileged helper. Not implemented; the app elevates per action through Authorization Services instead (which does cache, so consecutive actions are usually silent).
- Offline mmdb country lookup. Deferred — country resolution is online-only.
- The app's AppKit/SwiftUI layer (
DezhbanMenu) is untested. Only the pure layer split intoDezhbanCore(Snapshot decoding, posture→icon derivation, settings-field batching) has a test target; the views, elevation, and CLI shell-out are still verified only by the manual checklists above.