Kessler is a ~3,700 line Go CLI tool that scans a developer's filesystem for build artifacts and deletes them — either to trash or permanently via os.RemoveAll. It includes an auto-pilot daemon that runs unattended via cron/launchd. The stakes for bugs in this codebase are high: a misidentified artifact means deleted source code.
engine/scanner.go:445-456 — isTrackedByGit() shells out to git ls-files. On any error (git not installed, corrupt index, permission denied, timeout under concurrent load), it returns false — "not tracked, safe to delete." This is the primary defense against deleting source code and it silently disables itself. It should fail closed.
cmd/daemon.go:59-116 — runDaemonCheck() scans the entire $HOME, collects artifacts older than 10 days exceeding 1GB, and trashes them with zero user interaction, zero logging, and no exclusion support (line 61 passes nil for exclusions). Combined with finding #1, if git errors under load, the daemon could trash tracked source code at 2 AM.
cmd/daemon.go:97 — absPath, _ := filepath.Abs(a.Path). If this fails, absPath is "" and MoveToTrash("") is called. Behavior on an empty path is platform-dependent and unpredictable.
cmd/clean.go:249-260 — When --force is used and trash fails (e.g., cross-partition), the code silently escalates to os.RemoveAll() — irrecoverable permanent deletion — without re-asking the user. The user specified --force to skip the initial confirmation, not to authorize permanent destruction.
engine/global.go:302-303 — If the native cache clean command doesn't exist, the fallback is os.RemoveAll(cache.Path). For the Nix store (/nix/store), Cargo cache, or any path where the command isn't installed, this could destroy entire managed environments.
cmd/daemon.go:325-330 — uninstallLinuxCron() discards the error from os.WriteFile for the temp crontab file. If the write fails, crontab tmpFile installs an empty/corrupt crontab, potentially wiping all of the user's cron jobs.
cmd/rules.go:25-56 — Downloads YAML from a hardcoded GitHub raw URL with no timeout, no size limit, no checksum, no signature, and no content validation. A compromised repo or MITM could serve rules that target src/, .git/, or any directory. Those rules then get auto-applied by the daemon.
engine/scanner.go:127-137 — Danger zone uses exact string equality (target.Path == dangerItem). A glob pattern like ".e*" matches .env via filepath.Glob but passes the danger zone check because ".e*" != ".env". Path variants (./.env, .env/) also bypass it.
cmd/daemon.go:267-268—exePathinterpolated directly into a crontab line. A binary at a path containing; rm -rf / #produces arbitrary cron execution.cmd/daemon.go:180-210—exePathandhomeinterpolated into plist XML without escaping.cmd/daemon.go:241-248—exePathembedded in schtasks/trargument with manual quoting.
engine/trash_windows.go:13-27 — File path embedded in PowerShell script. While single quotes are escaped, backtick characters (valid in NTFS filenames) are PowerShell escape characters and are not handled.
cmd/daemon.go:153,181,230 and engine/doctor.go:58,111,169,218,267,325 — When home is "", subsequent filepath.Join constructs paths rooted at /. On macOS, installMacLaunchAgent (line 181) would write a LaunchAgent plist to /Library/LaunchAgents/ — a system directory.
assets/default-rules.yaml — Several rules target directory names that are commonly used for non-artifact purposes:
| Target | Tier | Risk |
|---|---|---|
env (Python, line 69) |
safe | Environment config directories |
build (6 rules) |
safe/deep | Dockerfiles, CI scripts, Makefiles |
dist, out, bin |
safe/deep | Scripts, compiled tools, data output |
coverage (Node, line 44) |
safe | Non-regeneratable reports |
vendor (Go, line 112) |
safe | Intentionally committed for reproducibility |
*.log (LaTeX, line 301) |
safe | Application/server logs in any dir with a .tex file |
engine/scanner.go:458-586 — Any directory in .gitignore not covered by existing rules is surfaced as TierIgnored. Common gitignored directories like data/, secrets/, backups/, uploads/, and database files are gitignored because they're sensitive, not because they're safe to delete.
assets/default-rules.yaml:274 — DESCRIPTION is an extremely generic filename. Any directory containing it is classified as an R project, and .RData files (potentially non-regeneratable analysis results) are targeted.
There are 0 test files in the entire codebase. Zero. For a tool whose primary function is deleting files from the filesystem. The daemon auto-deletion logic, danger zone safety net, git tracking check, rule matching, config merging, glob handling — none of it has a single test. This is the single most important finding.
There are 0 interfaces in the codebase. Every function directly calls os.Stat, exec.Command, filepath.WalkDir, os.RemoveAll, os.ReadFile, etc. The Scanner struct cannot be tested without hitting the real filesystem and requiring real git, npm, cargo, etc. to be installed. No dependency injection exists.
.github/workflows/release.yml triggers only on tag push for GoReleaser. There is no CI workflow for PRs or pushes. No go test, go vet, staticcheck, or golangci-lint runs at any point. Code is compiled and shipped without any automated quality gate.
internal/tui/tui.go is 1833 lines with View() spanning 871 lines (lines 891-1762) and Update() at 423 lines (lines 437-860). The entire TUI — model definition, all 4 tabs, all dialogs, scanning view, cleaning view, file tree renderer, sparkline renderer, icon mapping — is in one file.
engine/scanner.go:61-202 (Scan) and engine/scanner.go:214-380 (ScanWithProgress) share ~90% identical code. The walk logic, rule matching, danger zone checking, glob expansion, git safety, and project assembly are duplicated.
Three independent deletion loops exist: cmd/clean.go:218-239, internal/tui/tui.go:316-334, and cmd/daemon.go:96-101. Each makes different safety decisions. The daemon has no fallback logic. The TUI has automatic escalation to permanent deletion. The CLI has conditional escalation with --force.
Not a single log.Printf, slog.Info, or any logging call exists. The daemon runs unattended with no way to diagnose failures. Failed deletions, failed git checks, failed cron operations — all invisible.
Channel buffer sizes, progress intervals, pagination offsets, staleness thresholds, size thresholds, truncation lengths, modal dimensions — all inline constants with no names or documentation.
cmd/rules.go:27 — The community rules download has no context.Context, no http.Client with timeout, no deadline. On a hanging network, this blocks the process indefinitely.
No scan, clean, or external command call accepts a context. There is no way to cancel a long-running operation. The TUI scan can leak goroutines if the user quits during scanning.
Across the codebase, errors from os.UserHomeDir, filepath.Abs, filepath.Match, os.MkdirAll, os.WriteFile, os.Remove, exec.Command().Run(), launchctl unload, crontab manipulation, beeep.Notify, lsof, and PID parsing are all discarded with _. The history persistence (engine/history.go:61-62) discards both MkdirAll and WriteFile errors.
engine/history.go:62 uses os.WriteFile which truncates then writes. A crash mid-write corrupts history. cmd/rules.go:47-56 creates then writes the community rules file; a network failure after os.Create leaves an empty file, destroying previously downloaded rules.
engine/types.go — Tier is a string alias with no validation. tier: "yolo" in YAML is silently accepted. A malformed ~/.config/kessler/rules.yaml makes the entire tool unusable with no fallback-to-defaults option.
engine/scanner.go:287-368 and internal/tui/tui.go:221-242 — Worker goroutines send on the progress channel (buffer 10). If the TUI consumer stops reading (user quits), goroutines block forever on channel send, wg.Wait() never returns, and the outer goroutine is leaked.
engine/history.go:46-63 — SaveEntry does a read-modify-write cycle on history.json with no file locking. The daemon and an interactive session running simultaneously can clobber each other's writes.
engine/history.go:62 — os.WriteFile truncates then writes. If the process is killed mid-write (OOM, power loss during daemon), the file is left truncated. Next LoadHistory finds corrupt JSON and silently returns empty history — all history is lost.
cmd/scan.go:182-193 and internal/tui/tui.go:151-162 — Identical function defined twice.
getUnusedNode, getUnusedRust, getUnusedPython, getUnusedRuby, getUnusedJava all follow the exact same pattern (~50 lines each) with only file names and commands changing. Should be table-driven.
engine/doctor.go:348-352 — The entire contents of rust-toolchain.toml is treated as a version string instead of parsing the TOML. The in-use detection will never match installed toolchains, causing all Rust toolchains to be reported as "unused."
assets/default-rules.yaml:422-438 — The rule has triggers: [] with a comment saying "applies everywhere globally," but scanner.go:382 skips rules with empty triggers. The rule is inert — a mismatch between intent and implementation.
.goreleaser.yaml:24 sets -X main.version={{.Version}} -X main.commit={{.ShortCommit}} but main.go has no version or commit variables defined. These linker flags silently do nothing.
cmd/root.go:14-23 — RulesData, CommunityRulesData, UserRulesData are mutable exported package-level variables set from main.go. Should be passed as constructor arguments.
engine/global.go — GlobalCache.Icon (an emoji) and GlobalCache.CleanCommand (display text) are presentation-layer concerns embedded in engine data types.
engine/scanner.go:95 — strings.Contains(path, pattern) is a substring match on the full path. An exclusion pattern of "o" would skip any directory whose path contains the letter "o."
internal/tui/tui.go:482-509 — X then y permanently deletes potentially gigabytes of data. a then X then y = select ALL then permanently delete ALL. No "type YES to confirm" safeguard for destructive operations.
There is no Makefile, Taskfile, or justfile. Developers have no standardized way to build, test, lint, or run the project.
| Severity | Count | Theme |
|---|---|---|
| CRITICAL | 8 | Git safety fails open; daemon auto-deletes untested; zero tests; untestable architecture; no CI |
| HIGH | 10 | Supply chain attack; command injection; overly broad targets; god file; code duplication; no logging |
| MEDIUM | 9 | Swallowed errors; non-atomic writes; no config validation; no context; magic numbers; TOCTOU |
| LOW | 13 | Duplicated helpers; broken version detection; inert rules; mutable globals |
-
Flip
isTrackedByGitto fail closed — if git errors, assume tracked, refuse to delete. This is a one-line change that eliminates the largest data loss vector. -
Make the daemon notify-only — scan and report what would be cleaned, but require the user to run
kessler cleanto act. Auto-deletion without tests is reckless. -
Add filesystem and command interfaces — make the engine testable, then write tests for danger zone checking, rule matching, and config merging.
-
Add a CI pipeline —
go vet,staticcheck, andgo teston every PR. -
Validate community rules — parse the YAML, reject rules targeting paths shorter than 3 characters or matching known source directories, add a size limit and timeout.