Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@
"on this machine read it — unlike settings.local.json (gitignored) and",
"~/.claude/settings.json (per-profile, keyed by CLAUDE_CONFIG_DIR).",
"Subagents inherit these, so approvals no longer have to be re-granted per agent.",
"Plan mode DOES honour this allow-list for read-only tools — it only blocks",
"mutations unconditionally. So anything read-only that prompts during planning",
"is simply missing from the list below; add it here rather than re-approving.",
"Plan mode blocks mutations unconditionally, and for an MCP tool it is the",
"SERVER that decides what counts: Claude Code trusts the tool's own",
"annotations.readOnlyHint and consults no allow-list at all in that mode.",
"So an MCP tool declaring readOnlyHint:false re-prompts forever no matter",
"what you add here — the wildcards below cannot fix it, and adding more",
"entries is a dead end. Verified 2026-07-28 against Claude Code 2.1.220.",
"Concretely, context-mode declares ctx_search/ctx_stats/ctx_doctor read-only",
"but ctx_execute/ctx_execute_file/ctx_batch_execute/ctx_index NOT read-only.",
"A PreToolUse hook returning permissionDecision:allow does NOT override the",
"gate either — built, installed and tested live 2026-07-28. Nor does Claude",
"Code expose any plan-mode tool allow-list setting. The only fix that works",
"is not calling those tools while planning: see the plan-mode paragraph in",
"~/.claude/CLAUDE.md, which routes planning through jcodemunch/serena/Read.",
"Caveat: Bash patterns match the whole command string, so a novel compound",
"command (a for-loop, an unlisted pipe) still prompts even when every program",
"inside it is listed. Prefer one allow-listed command per call."
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,31 @@ jobs:
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test ./...
# The profile is written on every OS but GATED on one (below): running the
# suite a third time just to produce it would triple ubuntu's test cost
# for a file only ubuntu reads.
- run: go test ./... -coverprofile=cover.out
# Race detector once (ubuntu): the runner toggles posture from a ticker
# loop with a recovery probe — catch data races there.
# loop with a recovery probe — catch data races there. Also the one
# uninstrumented run of the whole suite.
- if: matrix.os == 'ubuntu-latest'
run: go test -race ./...
# Coverage gate once (ubuntu): enforces .testcoverage.yml's per-package
# floors so coverage cannot regress. See docs/contribute/testing.md's
# Unit test policy for what counts. The tool version is PINNED, never
# @latest — this gate decides whether CI goes red, so an upstream release
# must not be able to change that verdict with no commit here. Keep in
# sync with Taskfile.yml's test:cover.
#
# Gated to ubuntu on purpose, and that is also why the floors in
# .testcoverage.yml are LINUX numbers: the backends are build-tagged, so
# macOS compiles pf_darwin.go/discover_darwin.go where Linux compiles
# nft_linux.go/discover_other.go. Same package name, different files,
# legitimately different coverage.
- if: matrix.os == 'ubuntu-latest'
run: |
go install github.com/vladopajic/go-test-coverage/v2@v2.19.0
"$(go env GOPATH)/bin/go-test-coverage" --config=.testcoverage.yml
# Keep the windows backend compiling even though we don't test it yet (see
# the matrix comment). Vet, not just build: it type-checks the _windows.go
# files that no runner in the matrix ever touches.
Expand Down
47 changes: 47 additions & 0 deletions .testcoverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# go-test-coverage config (github.com/vladopajic/go-test-coverage). Dev-only
# tool, installed on demand by `task test:cover` and CI — never added to
# go.mod. See docs/contribute/testing.md's "Unit test policy".
#
# Thresholds below are today's real, measured coverage per package (`go test
# ./... -cover`), rounded down for margin — not an aspirational number. The
# point of the gate is to stop coverage from REGRESSING, not to pretend every
# package is already where it should be. Raise a package's own threshold
# whenever a change pushes its real coverage up; that is the intended way
# this file grows over time.
#
# THE NUMBERS ARE LINUX NUMBERS, because the gate runs only on ubuntu (see
# .github/workflows/ci.yml). Two packages are build-tagged and therefore
# genuinely different bodies of code per OS — internal/firewall compiles
# pf_darwin.go on macOS and nft_linux.go on Linux, internal/netdetect compiles
# discover_darwin.go vs the much smaller discover_other.go. `task test:cover`
# on a Mac measures those files, not the ones CI will. If a local run passes
# and CI's gate fails (or vice versa) on one of those two, that is why, and
# the number to record here is CI's.
profile: cover.out
threshold:
package: 60
total: 55
override:
# OS-boundary packages: what they DO is verified by
# docs/contribute/testing.md's privileged, on-host checklist, not go test.
# Forcing these toward the default package threshold would mean asserting on
# exact pfctl/nft/launchd argv strings — change detectors that break on
# harmless refactors and prove nothing about correctness.
- path: ^internal/firewall$ # shells out to pfctl/nft/netsh
threshold: 35
- path: ^internal/svc$ # wraps kardianos/service -> launchd/systemd/Windows SCM
threshold: 5
- path: ^internal/netdetect$ # reads real network interfaces and routes
threshold: 50
# cmd/dezhban mixes a testable read-only CLI surface (see
# cmd/dezhban/harness_test.go's runCLI, and the plan functions pulled out of
# cmdBlock/cmdRun) with privileged commands that re-exec sudo, install an OS
# service, or call firewall.Apply directly. Those stay uncovered by design.
- path: ^cmd/dezhban$
threshold: 35
exclude:
# Unlike override (package paths, exact match), exclude matches FILE paths —
# a trailing $ here would never match a real .go file, so these are prefixes.
paths:
- ^internal/privilege/ # Geteuid wrapper + a Windows syscall
- ^tools/ # build/dev tooling, never shipped
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,71 @@ current as you land changes.

## [Unreleased]

### Added

- **A checked-in test-quality gate.** `task test:cover` (and CI, once per run)
now enforces `.testcoverage.yml`'s per-package coverage floors via
`go-test-coverage`, pinned to today's real measured coverage rather than an
aspirational number — the point is to stop regressions, not to pretend every
package is already where it should be. See docs/contribute/testing.md's new
"Unit test policy" section for the rules a new test is expected to follow.
- `cmd/dezhban/harness_test.go` exercises the CLI's read-only command surface
in-process (`run()` itself was previously untested), and `blockPlan`/
`parseOverrides` pull pure decision logic out of `cmdBlock`/`cmdRun` so it is
testable without root or a firewall backend.
- **`scripts/install.sh` is now interactive at a real terminal.** Piped
(`curl | sudo bash`) it is unchanged — no prompt, today's exact defaults.
Run directly instead, it asks: which components to install on a fresh
machine (menubar app, service registration), and on a machine that already
has dezhban, upgrade / reinstall / **uninstall** (typed confirmation,
keep-config prompt, defaults to keeping `/etc/dezhban`). Progress now shows
`[n/N]` step counters, with curl's own progress bar on a real terminal.
- **`dezhban upgrade can-activate [--json]`** — a read-only, no-root
subcommand reporting whether a restart could activate right now (the same
gate `upgrade apply` uses). See "Fixed" below for why `install.sh` needed it.
- **[docs/usage/passwordless.md](docs/usage/passwordless.md)** — a task-oriented
tutorial for pointing `control.group` at your distro's existing admin group
(`sudo`/`wheel`/`admin`) so day-to-day ops (`block`, `unblock`, `switch`,
`pause`, `resume`, `hold`) no longer prompt for a password. Grants no new
authority — if you can already `sudo`, you're already a member. Bundled into
the macOS app's Help pane.
- `dezhban doctor` gained a **`control:`** check: socket reachability, the
configured group, whether the caller is in it, and which ops
`allowSwitchOps`/`allowPauseOps`/`allowConfigOps` force back to `sudo`.

### Fixed

- Replaced six blind `time.Sleep`-and-hope waits in `internal/runner` and
`internal/control`'s tests with bounded polling (or, in one case, an
explicitly documented exception where the sleep IS the scenario under test).
- **`scripts/install.sh`'s upgrade path could restart a running daemon through
an unsafe posture.** It stopped and restarted the service unconditionally,
with no regard for ADR-0007's activation gate — including through FULL
BLOCK, which would have briefly lifted a block on a forbidden-country exit,
the one thing this tool exists to prevent. It now checks `dezhban upgrade
can-activate` before stopping anything: the new binary always installs (safe
— a running process keeps its old inode), but the stop/restart is skipped on
refusal, leaving the old build enforcing until `sudo dezhban restart`
succeeds on its own. No override, matching `upgrade apply`'s own rule.
- **`scripts/install.sh` could finish with the kill switch disarmed and say
nothing.** When an upgrade restarted a running daemon, the `start` was
unguarded: under `set -e` a failure aborted the script with no message at
all — after the `stop` had already run, so every firewall rule was gone.
The install looked like it had merely "failed" while the host was in fact
left unprotected. It now stops with an explicit warning that names the
exposure and the command that ends it. Relatedly, cancelling the optional
setup wizard on a fresh install no longer swallows the "next steps" footer.
- **Docs and `doctor`/`setup` hints no longer model `sudo` on commands that
don't need it.** `status` never needed root — its `doctor` fix hint wrongly
said otherwise. `block`, `unblock`, `switch`, `pause`, `resume` route over
the control socket before ever needing root; `sudo` in front of them was
actively counterproductive; it re-execs as root, which then pays the very
password prompt the socket exists to avoid. Trimmed from the user-facing
`docs/usage/` pages; ADRs, contributor docs, and every `curl | sudo bash`
install line are untouched by design, and the `sudo` still shown on `panic`,
`setup`, `run`, and the service lifecycle is correct — those genuinely need
root.

## [0.8.0] - 2026-07-28

### Changed
Expand Down
16 changes: 16 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,22 @@ tasks:
cmds:
- go test ./...

test:cover:
desc: 'Run the suite with coverage and enforce .testcoverage.yml'
vars:
# Pinned, never @latest: the gate decides whether CI goes red, so a new
# upstream release must not be able to change that verdict without a
# commit here. Bump deliberately. Keep in sync with .github/workflows/ci.yml.
COVERAGE_TOOL: github.com/vladopajic/go-test-coverage/v2@v2.19.0
cmds:
- go test ./... -coverprofile=cover.out
# Unconditional install, not "skip if the binary exists": `go install` is
# a fast no-op once the module is cached, and skipping would silently keep
# whatever version a developer installed first — which is exactly what the
# pin above exists to prevent.
- go install {{.COVERAGE_TOOL}}
- '"$(go env GOPATH)/bin/go-test-coverage" --config=.testcoverage.yml'

lint:
cmds:
- |
Expand Down
142 changes: 142 additions & 0 deletions cmd/dezhban/doctor_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package main

import (
"errors"
"io"
"log/slog"
"net/netip"
"strings"
"testing"

"github.com/behnam-rk/dezhban/internal/config"
"github.com/behnam-rk/dezhban/internal/control"
"github.com/behnam-rk/dezhban/internal/netdetect"
)

Expand Down Expand Up @@ -64,6 +66,146 @@ func TestBuildTunnelsCheck(t *testing.T) {
})
}

func TestBuildControlCheck(t *testing.T) {
base := func() config.Config {
cfg := config.Default()
cfg.Control.Enabled = true
cfg.Control.Group = "admin"
return cfg
}

t.Run("disabled", func(t *testing.T) {
cfg := base()
cfg.Control.Enabled = false
c := buildControlCheck(&cfg, control.Response{}, nil)
if c.Status != checkWarn {
t.Errorf("status = %q, want %q", c.Status, checkWarn)
}
if !strings.Contains(c.Summary, "disabled") {
t.Errorf("summary = %q, want it to say disabled", c.Summary)
}
// `config set` is in the privileged set — it needs an enrolled control
// token, not just group membership. A fix modelled without sudo tells
// the user to run something that fails, on the one check whose whole
// subject is which commands need a password.
if len(c.Fixes) != 1 || !strings.HasPrefix(c.Fixes[0], "sudo dezhban config set control.enabled") {
t.Errorf("fixes = %v, want a single `sudo dezhban config set control.enabled true`", c.Fixes)
}
})

t.Run("forbidden — reachable but not in the group", func(t *testing.T) {
cfg := base()
c := buildControlCheck(&cfg, control.Response{}, control.ErrForbidden)
if c.Status != checkWarn {
t.Errorf("status = %q, want %q", c.Status, checkWarn)
}
if !strings.Contains(c.Summary, "not in the") || !strings.Contains(c.Summary, "admin") {
t.Errorf("summary = %q, want it to name the group", c.Summary)
}
// Adding an account to a unix group is usermod on Linux and dseditgroup
// on macOS — no portable command to offer, so this branch must explain
// rather than invent one.
if len(c.Fixes) != 0 {
t.Errorf("fixes = %v, want none — there is no portable add-to-group command to badge", c.Fixes)
}
if !strings.Contains(strings.Join(c.Details, "\n"), "passwordless.md") {
t.Errorf("details = %v, want the doc pointer", c.Details)
}
})

// doctorCheck.Fixes is documented as "commands or actions ... never prose
// about them — the GUI badges each one". A sentence in that slot renders as
// a command the user should type, so every branch is checked, not just the
// ones a case above happens to exercise.
t.Run("no branch puts prose in Fixes", func(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(*config.Config)
resp control.Response
probeErr error
}{
{"disabled", func(c *config.Config) { c.Control.Enabled = false }, control.Response{}, nil},
{"forbidden", nil, control.Response{}, control.ErrForbidden},
{"unreachable", nil, control.Response{}, errors.New("dial: no such file")},
{"unreachable, no group", func(c *config.Config) { c.Control.Group = "" }, control.Response{}, errors.New("dial: no such file")},
{"reachable, no group", func(c *config.Config) { c.Control.Group = "" }, control.Response{OK: true}, nil},
{"reachable and a member", nil, control.Response{OK: true}, nil},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := base()
if tc.mutate != nil {
tc.mutate(&cfg)
}
for _, f := range buildControlCheck(&cfg, tc.resp, tc.probeErr).Fixes {
if !strings.HasPrefix(f, "dezhban ") && !strings.HasPrefix(f, "sudo dezhban ") {
t.Errorf("fix %q is not a runnable command — prose belongs in Details", f)
}
}
})
}
})

t.Run("unreachable — no daemon running", func(t *testing.T) {
cfg := base()
c := buildControlCheck(&cfg, control.Response{}, errors.New("dial: no such file"))
if c.Status != checkWarn {
t.Errorf("status = %q, want %q", c.Status, checkWarn)
}
if !strings.Contains(c.Summary, "unreachable") {
t.Errorf("summary = %q, want it to say unreachable", c.Summary)
}
})

t.Run("unreachable and no group configured names both problems", func(t *testing.T) {
cfg := base()
cfg.Control.Group = ""
c := buildControlCheck(&cfg, control.Response{}, errors.New("dial: no such file"))
if len(c.Details) == 0 || !strings.Contains(c.Details[0], "no group") {
t.Errorf("details = %v, want a note about the missing group", c.Details)
}
})

t.Run("reachable, no group configured", func(t *testing.T) {
cfg := base()
cfg.Control.Group = ""
c := buildControlCheck(&cfg, control.Response{OK: true}, nil)
if c.Status != checkWarn {
t.Errorf("status = %q, want %q", c.Status, checkWarn)
}
if !strings.Contains(c.Summary, "no group") {
t.Errorf("summary = %q, want it to say no group is configured", c.Summary)
}
})

t.Run("reachable and a member — the happy path", func(t *testing.T) {
cfg := base()
c := buildControlCheck(&cfg, control.Response{OK: true}, nil)
if c.Status != checkOK {
t.Errorf("status = %q, want %q", c.Status, checkOK)
}
if !strings.Contains(c.Summary, "need no password") {
t.Errorf("summary = %q, want it to confirm no password is needed", c.Summary)
}
})

t.Run("gated ops are named regardless of reachability", func(t *testing.T) {
cfg := base()
cfg.Control.AllowSwitchOps = false
cfg.Control.AllowConfigOps = false
c := buildControlCheck(&cfg, control.Response{OK: true}, nil)
joined := strings.Join(c.Details, "\n")
if !strings.Contains(joined, "allowSwitchOps=false") {
t.Errorf("details = %v, want the switch gate named", c.Details)
}
if !strings.Contains(joined, "allowConfigOps=false") {
t.Errorf("details = %v, want the config-write gate named", c.Details)
}
if strings.Contains(joined, "allowPauseOps=false") {
t.Errorf("details = %v, want the pause gate NOT named (it wasn't disabled)", c.Details)
}
})
}

func TestBuildEndpointsCheck(t *testing.T) {
t.Run("no endpoints resolved", func(t *testing.T) {
c := buildEndpointsCheck(nil, nil)
Expand Down
Loading