Skip to content

feat(diag): show applied and pending firewall rules - #53

Merged
Behnam-RK merged 8 commits into
mainfrom
feat/diagnostics-firewall-rules
Sep 6, 2026
Merged

feat(diag): show applied and pending firewall rules#53
Behnam-RK merged 8 commits into
mainfrom
feat/diagnostics-firewall-rules

Conversation

@Behnam-RK

@Behnam-RK Behnam-RK commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Stacked on #52#51#50#49.

Diagnostics gains a Firewall rules section with three sources, because they answer three different questions and are not interchangeable:

Question Cost
Applied by dezhban What did dezhban install, and when? Free, no root, every platform
In the kernel now What does the firewall actually hold? One password prompt, on demand
Would apply What does FULL BLOCK / guard / switch do? Free, no root, no firewall effects

Each carries a plain-language caption saying what that posture does to your traffic — a ruleset is not self-explanatory to the person most likely to be reading it.

Recording what was applied

internal/applied writes the exact ruleset text handed to the backend, timestamped, beside state.json at 0644 like the state file — so the unprivileged menubar app can read it without root. It holds nothing print-rules would not print for free.

Recorded by wrapping the runner's Backend, not by adding applied.Save beside each Apply. The run loop applies from nineteen places, and a record that is only as complete as the last person to remember it is worse than none — a surface would show a stale posture with no way to tell. Wrapping makes a new call site recorded by construction.

The wrapper adds no goroutine and no writer: every method is called from the run-loop goroutine by the same code that called the backend before, so CLAUDE.md's single-writer invariant is untouched. The write is an atomic replace of a small file — bounded work on the goroutine that owns window expiry and geo ticks, which is why it must stay that shape.

Two properties worth calling out:

  • It records only after a successful Apply. A failed apply leaves the previous ruleset live, so recording the attempt would describe rules that were never installed — the one thing a reader of this file must be able to rely on not happening.
  • Unblock and Cleanup clear it, even when they fail. A record surviving teardown would be read as the live posture: a pane saying "guard is enforcing" over a wide-open network.

It wraps runner.Backend (the narrow enforcement interface), not firewall.FirewallBackend, so the diagnostic read below does not end up on the seam enforcement uses.

Reading the kernel back

FirewallBackend gains InstalledRules() (string, bool, error), implemented for all three backends:

  • pfpfctl -a dezhban -s rules, plus a warning line when the main ruleset no longer references the anchor (loaded but never descended into — the same gap IsBlocked checks).
  • nft — reuses the existing listTable, plus a warning when the output chain's policy has drifted off drop.
  • WFPGet-NetFirewallRule -Group dezhban plus each profile's DefaultOutboundAction, since on Windows that is where the blocking actually lives.

Every one is scoped to dezhban's own anchor/table/group, so this can never become a way to dump a user's unrelated firewall configuration. It is a read: it does not go through Apply and does not touch the single-writer rule, so any goroutine or process may call it. It needs root, which is why nothing calls it on a tick and the daemon never calls it at all.

Drift is reported, never repaired

When dezhban has a record of applying rules and the kernel holds none, both the CLI and the pane say so — and offer no repair. The run loop's VerifyInterval tick already re-applies missing rules; a repair button would be a second writer of the firewall.

Neither surface diffs the two texts. pfctl -s rules renders a normalised form of what was loaded, so a byte comparison would report drift on every healthy host. The texts are shown for a person to read, and the drift flag is the narrow, reliable signal.

CLI

dezhban print-rules --applied            # what dezhban recorded installing
sudo dezhban print-rules --installed     # what the firewall itself holds

--json on either. Passing both is refused with an explanation, because they are two different sources rather than two views of one. Without root, --installed fails with the sudo hint rather than a bare permission error.

"Nothing recorded" exits 0, not 1 — a daemon in standby has applied nothing, and that must be distinguishable from a failure.

Also

The Diagnostics previews are lazy: expanding a posture spawns its print-rules subprocess, collapsed ones spawn nothing. Rendering all three on every visit to the pane would be three processes nobody asked for.

Verification

  • go build, go vet, go test — pass, including GOOS=linux and GOOS=windows vet for the two backends this machine cannot run.
  • New internal/applied tests: round trip, 0644 (the GUI has to read it), missing-is-not-an-error, Remove idempotent, corrupt-is-discarded-not-fatal.
  • New internal/runner tests: what gets recorded matches RenderRules for the same policy, a failed apply leaves the previous record intact, Unblock/Cleanup clear it, an empty path returns the backend unwrapped, and a nil logger does not panic (Run never defaults Log).
  • swift test — 256 tests (205 when this was opened; the rebase onto main and the review rounds below added the rest). RulesetsTests covers Go's RFC 3339 fractional timestamps, which Foundation's .iso8601 strategy rejects outright — that would have turned a good record into "no rules recorded" while the guard was enforcing.
  • print-rules --applied and --installed exercised directly; the unprivileged --installed path produces the intended refusal and hint.
  • build-app.sh assembles cleanly.

The parts CI cannot reach are in docs/contribute/testing.md under a new "Firewall rules (Diagnostics)" section — most importantly: teardown clears the record, the readback changes nothing, and flushing the anchor by hand produces a warning with no repair button while the daemon's own verify tick heals it.


Rebase onto main

Opened stacked on #52, which has since merged. The branch carried its own copies of #51's and #52's work; both were superseded in main (#51 merged in a revised form, #52 merged with four follow-up commits), so the rebase drops them and replays only this branch's commit.

Review loop

Three rounds plus a confirmation pass, with two independent reviewers — GitHub Copilot on the PR and a read-only local agent. Every fix was applied by the orchestrator, not the reviewers, and each carries a test that fails against the unfixed code.

Round Findings Pre-existing Loop-introduced Headline
1 16 16 0 panic and unblock never cleared the applied record
2 7 4 3 the drift branch discarded the text proving a Windows lockout
3 9 4 5 a loaded firewall that filters nothing read as healthy
confirm 2 0 2 a warning absorbed the ordinary comment printed after it

Stopped after round 3 on the stated condition — the loop's own findings outnumbered the branch's. The confirmation pass ran only because round 3's fixes were themselves unreviewed, and it returned merge-ready.

The three that mattered

The record outlived the rules it described. panic and unblock tear the firewall down through a raw backend, and only the runner's decorator knew how to clear applied-rules.json. After sudo dezhban panic the record survived, so print-rules --applied and the Diagnostics pane both reported "guard applied at 14:02" over a network that command had just thrown wide open. panic is the worst possible place for it: it is deliberately independent of the running service, so the deferred Cleanup that normally clears the record never runs, and it is the moment an operator is asking precisely whether the rules are gone. Both paths now clear it, and clear it even when teardown reports an error. block had the mirror gap — rules installed by hand were absent from a diagnostic claiming to show what dezhban applied.

Loaded is not enforcing. pf switched off with pfctl -d, an anchor the main ruleset stopped referencing, an nft chain drifted off drop — in all three the rules are entirely present and nothing is filtered. The backends detected each case but wrote the answer only as a # WARNING: comment inside the returned text, so a JSON consumer read {"loaded":true,"drift":false} and concluded healthy, and the pane drew a collapsed disclosure with nothing visibly wrong. Finding out the kill switch was not switching anything off required expanding it and reading pf syntax. Now enforcing and warnings in --json, an orange row that needs no expanding, and an on-host check that reproduces it.

Windows reported a lockout as standby. Remove-NetFirewallRule -Group dezhban removes only the allow rules, so a profile still set to Block is fully cut with no group present — and the readback returned before ever emitting the profile table.

Regressions the loop caused and then fixed

Named rather than absorbed, since they are not the branch's:

  • Round 1's Go-only import gate created a Go/Swift divergence — the app imported profile files a user had withdrawn.
  • Round 1 taught the not-loaded branch to print the readback text and left the drift branch — the one actually taken when a record exists — discarding it.
  • A pf status probe reusing a context that a sibling read, ten lines away, documented as wrong to share.
  • An AST guard that under-bit the exact deletion class its own comment claimed to catch (cmdBlock records from two branches; removing either alone kept it green).
  • Widening the pane's visibility gate made the outer else unreachable, silently deleting the "Run diagnostics" prompt.
  • A failed applied.Save left the previous posture on disk, in both writers.
  • Warnings() folded a warning into the ordinary comment printed after it, producing a self-contradicting sentence in the pane's orange row.

Known and deliberately not fixed here

  • enforcing degrades to loaded on Windows. WFP emits no warning line, so a host with the group present and DefaultOutboundAction no longer Block reports enforcing while filtering nothing. Doing it properly means comparing live defaults against appliedActionPath() the way IsBlocked does, which is not code to write blind on a platform CI does not yet cover. Named in a TODO at the call site, on the JSON field, and as a caveat in cli.md.
  • InstalledRules models group-presence, not effective blocking. Same root, same reason; the profile table is now printed in every branch so the data is at least visible.
  • DezhbanCLI.readAppliedRules returns nil for both "nothing recorded" and "could not check." Errs in the safe direction — it never claims enforcement that is not there.

Not examined

The prose. docs/usage/cli.md, docs/contribute/testing.md, docs/concepts/modes.md and the changelog entry. Reviewers summarize prose rather than citing lines in it, so no round genuinely examined the writing. Its factual claims were checked against the code every round — that is what caught two false on-host checks and an unqualified enforcing claim — but a human read is still outstanding.

🤖 Generated with Claude Code

@Behnam-RK
Behnam-RK force-pushed the feat/diagnostics-firewall-rules branch from b000516 to 0dd295f Compare August 22, 2026 07:11
Base automatically changed from feat/two-step-wizard to main September 6, 2026 05:42
Three sources, because they answer three different questions and are not
interchangeable.

What dezhban recorded installing. internal/applied writes the exact ruleset text
handed to the backend, timestamped, beside state.json at 0644 like the state file
— so the unprivileged menubar app can read it. Recorded by wrapping the runner's
Backend rather than by calling Save at each Apply: the run loop applies from
nineteen places, and a record only as complete as the last person to remember it
is worse than none. The wrapper adds no goroutine and no writer, so the
single-writer invariant is untouched, and it records only after a successful
Apply — a failed one leaves the previous ruleset live, and describing rules that
were never installed is the one thing a reader of this file must be able to rely
on not happening. Unblock and Cleanup clear it, so a stale ruleset can never be
read as the live posture.

What the kernel holds. FirewallBackend gains InstalledRules, implemented for pf,
nft and WFP, each scoped to dezhban's own anchor/table/group so it can never
become a way to dump unrelated firewall state. It is a read: it does not go
through Apply and does not touch the single-writer rule. It needs root, which is
why nothing calls it on a tick. pf and nft additionally flag the loaded-but-not-
evaluated cases their IsBlocked already checks for.

What each posture would apply, which print-rules already rendered purely.

A record with no kernel rules is reported and never repaired — the run loop's
verify tick already owns that, and a second repairer would be a second writer.
Neither surface diffs the two texts: the kernel renders its own normalised form
of what was loaded, so a byte comparison would report drift on every healthy
host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK force-pushed the feat/diagnostics-firewall-rules branch from 0dd295f to 9167eaf Compare September 6, 2026 05:54
@Behnam-RK
Behnam-RK requested a balanced review from Copilot September 6, 2026 05:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several failure and teardown paths can leave diagnostics stale or incorrectly report the firewall’s actual state.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds firewall-rule diagnostics across the daemon, CLI, platform backends, and macOS app.

Changes:

  • Records successfully applied rulesets.
  • Adds applied, installed, and preview rule views.
  • Documents and tests the diagnostic workflow.
File summaries
File Description
CHANGELOG.md Records the feature.
cmd/dezhban/main.go Adds CLI flags and output.
docs/concepts/modes.md Explains rule sources.
docs/contribute/testing.md Adds on-host checks.
docs/usage/cli.md Documents CLI usage.
gui/macos/Sources/DezhbanCore/Rulesets.swift Defines diagnostic models.
gui/macos/Sources/DezhbanMenu/AppState.swift Manages rule reads.
gui/macos/Sources/DezhbanMenu/DezhbanCLI.swift Invokes CLI diagnostics.
gui/macos/Sources/DezhbanMenu/DiagnosticsView.swift Renders firewall diagnostics.
gui/macos/Tests/DezhbanCoreTests/RulesetsTests.swift Tests model decoding.
internal/applied/applied.go Persists applied rules.
internal/applied/applied_test.go Tests persistence behavior.
internal/firewall/backend.go Adds installed-rule readback.
internal/firewall/nft_linux.go Implements nft readback.
internal/firewall/pf_darwin.go Implements pf readback.
internal/firewall/render_darwin.go Names pf rulesets.
internal/firewall/render_linux.go Names nft rulesets.
internal/firewall/render_windows.go Names WFP rulesets.
internal/firewall/wfp_windows.go Implements Windows readback.
internal/runner/recording.go Wraps backend recording.
internal/runner/recording_test.go Tests recording behavior.
internal/runner/runner.go Configures recording path.
Review details
  • Files reviewed: 22/22 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/dezhban/main.go
PollCommand: pollCommand,
Publish: publish,
BlockedCountries: cfg.BlockedCountries,
AppliedRulesPath: applied.Path(stateDir()),
Comment thread internal/firewall/wfp_windows.go Outdated
Comment on lines +218 to +220
"$g = Get-NetFirewallRule -Group " + groupName + " -ErrorAction SilentlyContinue",
"if ($null -eq $g) { 'NONE'; exit 0 }",
"'# default outbound action per profile'",
Comment on lines +63 to +64
if err := r.Backend.Apply(p); err != nil {
return err
Comment on lines +283 to +287
static func readAppliedRules() -> AppliedRuleset? {
guard let bin = binaryPath() else { return nil }
let r = exec(bin, ["print-rules", "--applied", "--json"])
guard r.status == 0, let data = r.out.data(using: .utf8) else { return nil }
return AppliedRuleset.decode(data)
Comment thread internal/firewall/pf_darwin.go Outdated
Comment on lines +229 to +236
if main, err := pfctlCtx(ctx, "", "-s", "rules"); err == nil {
if mainRulesetReferencesAnchor(main) {
b0.WriteString("# main ruleset references the dezhban anchor\n")
} else {
b0.WriteString("# WARNING: the main ruleset does NOT reference the dezhban anchor —\n")
b0.WriteString("# these rules are loaded but pf never descends into them.\n")
}
}
Behnam-RK and others added 2 commits September 6, 2026 09:31
Found reading the diff before review, not by the reviewer.

print-rules now carries two kinds of flag: one describing a ruleset to
RENDER (--mode) and two selecting a live ruleset to REPORT (--applied,
--installed). Only the --applied/--installed pair was refused. The other
two combinations were accepted and half-discarded:

  print-rules --applied --mode fullblock   # --mode ignored, exit 0
  print-rules --json                       # --json ignored, prints text

Both are the shape this project calls its worst bug — a flag accepted and
then quietly dropped — and the second is the more misleading, since a
caller parsing that output gets firewall syntax where it asked for JSON.
Each is now refused with exit 2 naming the flag to drop, matching the
existing --applied/--installed refusal.

The check is on what the user TYPED, via fs.Visit, not on flag values:
--mode defaults to "guard", so testing its value would reject every plain
--applied run. TestPrintRulesAppliedIsFineWithoutMode pins that, and
TestPrintRulesRefusesFlagsItCannotHonour covers all four refusals — the
two new cases return 0 and 1 on the unfixed code.

Also: CLAUDE.md said the privileged set was "exactly" a list that did not
include this, and listed print-rules among the commands needing no root.
`--installed` reads the kernel back and does need it. Corrected using the
same "X but not X --sub" idiom the paragraph already uses for `setup` and
`vpn list`, and the refusals are documented in cli.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 1 of the review loop, from two independent reviewers (GitHub
Copilot on the PR and a read-only local agent) that agreed on the
headline finding.

**`panic` and `unblock` never cleared the record.** Both tear rules down
through a raw `firewall.New()` backend, and only the runner's decorator
knew how to clear `applied-rules.json`. So after `sudo dezhban panic` the
record survived, and `print-rules --applied` and the Diagnostics pane both
went on reporting "guard applied at 14:02" over a network that command had
just thrown wide open. `panic` is the worst place for this: it is
deliberately independent of the running service, so the deferred Cleanup
that normally clears the record never runs, and it is the moment an
operator is asking precisely whether the rules are gone. Both paths now
clear it, and clear it even when the teardown reported an error — the
rules are then in an unknown state, and a record that confidently names
the old posture is worse than none.

**`block` recorded nothing.** The mirror of the same gap: rules installed
by hand were absent from a diagnostic that claimed to show what dezhban
had applied. It understates rather than overstates, but a record that is
only truthful when the service happened to be enforcing is not one an
operator can use.

Also from the same round:

- Windows reported "no dezhban rules are loaded" for a host that is fully
  cut. `Remove-NetFirewallRule -Group dezhban` takes away only the allow
  rules, so a profile whose `DefaultOutboundAction` is still `Block` is
  enforcing with no group present — and the readback returned before ever
  emitting the profile table. The defaults are now read first and
  unconditionally, and the CLI prints the text even when no group is
  loaded. The "no rules" answer is also found as its own line rather than
  by matching the whole output, since `-ErrorAction SilentlyContinue`
  leaves warnings on the success stream — incidental text made "no rules"
  read as "rules loaded" and displayed the noise as the kernel's ruleset.
- pf dropped its anchor-reference verdict silently when the main-ruleset
  read failed, so "could not check" rendered identically to "checked,
  fine" — for the exact non-enforcing state that check exists to expose.
  It now says so, and gets its own timeout instead of the remainder of
  the anchor read's.
- `--installed --json` printed a stderr note on a corrupt record. The app
  captures stdout and stderr together, so that prepended prose to the
  document and turned a good privileged readback into an error in the
  pane. Human output only now.
- A corrupt record made `--applied` exit 1, contradicting
  internal/applied's own "discarded, never fatal" contract. It is now
  reported on stderr and treated as absence, which is what the contract
  says and what the test name already claimed.
- `--config` was accepted and discarded on `--applied`/`--installed` —
  the same shape 48340c2 refused for `--mode`, one flag over.
- The kernel readback is a snapshot nothing refreshes, shown under "In
  the kernel now". Read during GUARD, it kept describing the firewall
  after FULL BLOCK engaged. It is now titled with the time it was read,
  cleared when the pane refreshes or closes, and its caption says it is a
  snapshot. Timestamps also carry their date: a record from three days
  ago rendered as a bare "14:02:11" reads as today.
- Two on-host checks asserted things a correct build fails: that `panic`
  cleared the record (it did not, until now), and that a collapsed
  Diagnostics pane spawns no `print-rules` — it always fetches the cheap
  `--applied --json` row. Both reworded, and the teardown check now names
  all three routes separately.
- CLAUDE.md: 48340c2 put `print-rules --installed` in the privileged set,
  but that set auto-re-execs under sudo via requireRoot and this
  deliberately does not — nor does it need elevation on Windows. Now
  described as what it is, outside the set.

Tests: teardown-clears and block-records (both fail on the unfixed code),
the corrupt-record exit status, the `--config` refusal, the Windows marker
scan against captured output, and the nanosecond timestamp form `time.Now()`
actually emits — the fixtures only covered six digits. The record path is
now injectable so these do not read the developer's own live ruleset off
/var/db/dezhban.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK requested a balanced review from Copilot September 6, 2026 06:55
@Behnam-RK Behnam-RK added the run-ci Run CI on this PR (gates .github/workflows/ci.yml) label Sep 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Stale records, asynchronous invalidation, malformed JSON handling, and Windows enforcement-state reporting can produce misleading diagnostics.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

gui/macos/Sources/DezhbanCore/Rulesets.swift:93

  • Defaulting every missing or mistyped field means any JSON object—including {}—decodes successfully as “no rules loaded,” causing Diagnostics to present a benign standby message for a malformed or incompatible CLI response. Treat the required fields as required so invalid output follows the existing error path instead of becoming false reassurance.
    internal/runner/recording.go:79
  • If this atomic save fails, the previous record remains on disk even though a different policy was successfully applied. Diagnostics will then present the old posture as the applied one; this conflicts with the wrapper's own rule that stale data is worse than no record. Clear the previous record on the save-failure path, as the WFP applied-action recorder already does.
  • Files reviewed: 25/25 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment on lines +243 to +245
// Text is returned either way: with no group there is still a profile table
// worth reading, and it is the half that says whether egress is cut.
return out, !hasNoRulesMarker(out), nil
Comment thread cmd/dezhban/main.go
Comment on lines +1242 to +1244
if err := applied.Save(appliedPath(), rec); err != nil {
fmt.Fprintln(os.Stderr, "warning — could not record the applied ruleset:", err)
}
Comment on lines +408 to +412
DispatchQueue.main.async {
guard let self else { return }
self.installedRulesRunning = false
if let decoded {
self.installedRules = decoded
Comment thread cmd/dezhban/main.go Outdated
validate Load and validate a config file (no root, no side effects)
monitor Live read-only view: IP, country, tunnel state, endpoints, verdict
print-rules Print the firewall ruleset a block/guard would apply, without applying it
print-rules Print the firewall ruleset a block/guard would apply (--applied: what is applied now)
Behnam-RK and others added 5 commits September 6, 2026 10:40
Round 2. The headline finding is the loop's own: round 1 taught the
no-rules branch to print the readback text, because on Windows the
blocking lives in each profile's DefaultOutboundAction rather than in the
rule group — and left the DRIFT branch, eight lines above, still throwing
it away. That is the branch taken whenever a record exists, which is
exactly when someone is asking. A host whose group was removed while its
profile default is still Block is fully cut, and was told "the kernel
holds no dezhban rules" with the profile table that proves egress is cut
discarded. Both branches print it now.

From the branch itself:

- pf checked two of the three things IsBlocked checks. An anchor that is
  loaded and referenced while pf is switched off entirely (`pfctl -d`)
  filters nothing, and rendered as a clean readback with no warning. The
  status probe is now there, in the same shape as the anchor-reference
  verdict, and says so.
- The whole Firewall-rules section sat inside the pane's
  `doctorReport != nil || vpnInventory != nil` gate, so the applied record
  and the "Read from the kernel…" button were invisible on a host where
  `doctor --json` cannot run — the state someone is most likely
  diagnosing — and on every first open until the async doctor returned.
  None of the three rows needs doctor. This is the same bug the comment
  right above that gate describes for the VPN inventory, one section over.
- The applied row put the posture in its title and the time only in its
  caption, so a pane held open across GUARD → FULL BLOCK kept reading
  "Applied by dezhban — Guard". The time is now in the title, where the
  claim is made. That read is unprivileged and cheap, which is why it gets
  a timestamp rather than the clearing the kernel row got.
- `--config` was refused but not documented; the help line named
  `--applied` but not `--installed`; and all three completion scripts
  offered neither.

The wiring, not just the helpers, is now tested. Both fixes this loop made
could be deleted with the whole suite still green:
TestEveryDirectFirewallPathKeepsTheRecordHonest walks main.go's AST and
fails when cmdBlock stops recording or cmdPanic/cmdUnblock stop clearing
(an AST guard because all three need root and a real firewall — same
technique, and same reason, as TestNoTestInPackageMainIsParallel), and
TestRunWiresTheRecordingBackend drives Run end to end and fails with
"recorded nothing — the backend was never wrapped" when the decorator is
unwired. Both were confirmed against the unfixed code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3, from the hosted reviewer's second pass. Two of the four findings
are the loop's own work.

**A failed save left a stale record, in both writers.** `atomicfile.Write`
leaves the old file in place when the replacement fails, so a successful
Apply whose record could not be written left the PREVIOUS posture on disk
being read as current — the exact failure the decorator exists to prevent,
arriving by the one path that looked like it merely lost information.
"Nothing recorded" is an ordinary answer; a confidently wrong posture is
not. Both `internal/runner/recording.go` and the CLI helper this loop added
now drop the old record when the write fails, the same shape as
`writeAppliedAction`'s failure path in wfp_windows.go, which already
carried this reasoning.

**An in-flight privileged read could repopulate a cleared snapshot.** The
kernel readback sits behind a password prompt, so pressing Run diagnostics
or leaving the pane while that prompt is open let the completion restore
exactly the snapshot the clear had invalidated — under a heading naming
when it was read. Each read now captures a generation that every clear
bumps, and a completion whose generation no longer matches is discarded.

**`{}` decoded as "no rules loaded, no drift".** Every field of an
installed-rules readback was optional-with-default, so any JSON object at
all became a confident standby message instead of taking the error path.
False reassurance about whether a kill switch is enforcing is the one
thing this surface must never produce. `loaded`, `drift` and `backend` are
required now; `installed` stays defaulted, since it is legitimately empty
when nothing is loaded.

**"No dezhban rules are loaded" asserted more than it knew.** On Windows
the blocking lives in each profile's DefaultOutboundAction, so an absent
rule group does not mean traffic is flowing. Both the CLI and the pane now
scope that claim to dezhban's own rules and show what the firewall actually
reported alongside it, rather than describing a possible lockout as standby.

Tests: TestAFailedSaveDropsTheStaleRecord (the save is injected, because
every way a real write fails either also breaks the removal under test or
cannot be provoked in a unit test — the struct already injects `now` for
the same reason), and aMalformedReadbackDoesNotDecodeAsStandby. Both
confirmed against the unfixed code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3, second pass. One branch defect and three regressions this loop
introduced itself.

**Loaded is not enforcing, and only the ruleset text said so.** pf
switched off with `pfctl -d`, an anchor the main ruleset no longer
references, an nft chain whose policy drifted off drop — in every one of
those the rules are all present and nothing is filtered. The backends
already detected each case, but encoded it ONLY as a `# WARNING:` line
inside the returned text: a JSON consumer saw `{"loaded":true,
"drift":false}` and concluded healthy, and the pane rendered a collapsed
disclosure with nothing visibly wrong, so an operator had to expand it and
read pf syntax to discover the kill switch was not switching anything off.
That is the state where every other signal reads healthy, which is exactly
why it needed to be the loudest.

`firewall.Warnings` makes those lines a contract rather than a formatting
choice, `--json` gains `enforcing` and `warnings`, the CLI prints them
above the ruleset, and the pane gets an orange row that needs no expanding.
The Swift side defaults both fields, so an older CLI degrades to the
previous behaviour rather than failing to decode.

The three the loop caused:

- pf's new status probe reused the context the anchor read had already
  spent from, while the main-ruleset read beside it allocated a fresh one
  with a comment explaining why sharing is wrong. Same hazard, opposite
  treatment, in one function — and a slow anchor read would have made a
  healthy host print "could not read pf's status … may be loaded but
  inert", the readback crying wolf about the exact condition it exists to
  report. Every probe now gets its own full budget, and the comment says
  why this differs from IsBlocked.
- The AST guard under-bit where it claimed to bite: `cmdBlock` records
  from two branches, and deleting either one alone left it green — the
  deletion class its own doc comment named. It counts call sites now, and
  its failure message distinguishes "you moved this into a helper, update
  the guard" from "you removed it", so a refactor is not reported as a
  security regression.
- Widening the pane's visibility gate to include `cliFound` made the outer
  `else` unreachable, silently deleting the "No results yet — run
  diagnostics" prompt, which is the pane's only call to action on a
  healthy host's first open. It lives inside the List now, beside the
  firewall rows.

TestWarningsAreFoundAndFolded covers the two-line warnings the backends
actually emit; the strengthened guard fails with "cmdBlock calls
recordAppliedBestEffort 1 time(s), want 2" when one site is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirmation round, and both findings are the loop's own from the previous
commit. Neither changes what is enforced.

**One warning is now exactly one line.** `Warnings()` folded a warning's
continuation lines into it and could not tell a continuation from the
ORDINARY comment that follows one. pf emits "# main ruleset references the
dezhban anchor" directly after a disabled-pf warning, so the fold produced
a single entry reading "…nothing is being filtered. Re-enable with `sudo
pfctl -e`. main ruleset references the dezhban anchor" — a sentence that
contradicts itself, shown in the pane's orange row and read as warnings[0]
by any JSON consumer, in exactly the scenario the on-host check tells a
tester to reproduce.

The heuristic is gone rather than tuned: the backends emit each warning on
one line and Warnings() just collects them. Long lines wrap; a rule that
guesses which comments belong together does not.
TestEveryEmittedWarningIsSelfContained pins the pf pair that broke it.

**`enforcing` is only as good as the warnings its backend emits**, and WFP
emits none. On Windows a host with dezhban's group present and a profile
whose DefaultOutboundAction is no longer Block reports enforcing while
filtering nothing. Deciding that properly means comparing the live defaults
against appliedActionPath() the way IsBlocked does, and that is not code to
write blind on a platform this repo does not yet test (see the matrix note
in ci.yml). So it is named where it lives — a TODO in wfp_windows.go, a
comment on the field, and a caveat in cli.md — rather than left as a
promise the field does not keep there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase onto post-release main put them inside `## [0.12.0]`. That
section is already tagged and published, and it does not contain any of
this work — merging it would have made main's changelog claim a shipped
release included a feature that was not in it, and left the next release
with an empty [Unreleased] despite a whole feature landing.

Both entries move to [Unreleased] under their own `### Added`. The
[0.12.0] section is now byte-identical to main's again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK merged commit e54db60 into main Sep 6, 2026
6 checks passed
@Behnam-RK
Behnam-RK deleted the feat/diagnostics-firewall-rules branch September 6, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Run CI on this PR (gates .github/workflows/ci.yml)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants