Generated: 2026-07-08 Method: 4 parallel audit agents + manual code review + E2E testing of all 52 Go source files
| Severity | Count | Confirmed | Fixed |
|---|---|---|---|
| Critical | 4 | 4 | 4 |
| High | 8 | 8 | 8 |
| Medium | 21 | 21 | 21 |
| Low | 7 | 7 | 7 |
| Total | 40 | 40 | 40 |
All fixes verified via live demo scenarios:
- Spin detection (repeated tools): Fires at 3rd identical call - PASS
- Spin detection (error echo): Fires at 3rd identical error - PASS
- Budget warning (80%): Fires at $4.38/$5.00 (88%) - PASS
- Budget exceeded (100%): Fires at $5.25/$5.00 (105%) with sentinel fallback - PASS
- Cost tracking: $13.12 accurately computed across 15 calls - PASS
- Desktop notifications: Sent for warnings and pauses - PASS
- Session auto-registration: New sessions picked up within 100ms - PASS
- Env var overrides: LOOPGUARD_BUDGET_PER_SESSION=5 honored - PASS
- No false positives: Normal sessions run without triggering alerts - PASS
- Concurrent sessions: Two sessions tracked independently - PASS
- Graceful shutdown: Clean shutdown with "daemon stopped" logged - PASS
- Cost tracking accuracy: Non-zero cost computed for sessions - PASS
E2E test suite: 16/16 passed (run via bash demo/test_e2e.sh)
- Race detector: No races detected (
go test -race)
- C3 (Critical): Auto-registered sessions wrote sentinel to wrong directory (decoded path vs storage path), causing enforcement to silently fail while reporting success. Fixed with
inferSessionInfo()that derives agent type from discoverer basePath and validates decoded project dir exists before using it. - M12 (Medium):
applyDefaultsprevented YAML from disabling budget limits (zero → default). Fixed to only apply defaults for non-budget structural fields; budget0now means "disabled" consistently between YAML and env vars. - M7 (Medium): Added
RemoveSession()to both Analyzer and BudgetEnforcer for session cleanup. - M10 (Medium): Resume API returns HTTP 404 for "session not found" (was 400).
- M18 (Medium): Codex sessions now set ProjectDir from session path.
- L1 (Low): Resume detects ambiguous prefix matches and asks for a longer prefix.
- L3 (Low): HTTP server has read/write/idle timeouts (10s/10s/60s).
- L4 (Low): Fixed variable shadowing in status.go.
- L6 (Low):
contentToString(nil)returns""instead of"null". - L7 (Low): Tailer handles oversized partial lines with
skipToNewlineflag to prevent garbled data.
- M14 (Medium): Kill follow-up now sends SIGKILL to entire process group (
-pgid) to clean up orphaned children. - M16 (Medium): Custom sessions check liveness via
lsofinstead of hardcodingActive: true. - M17 (Medium): Each custom path gets its own discoverer instance so all directories get fsnotify watches.
- L2 (Low): History file opened with
O_NOFOLLOWto eliminate TOCTOU symlink race window. - L5 (Low):
StartedAtread from first JSONL line timestamp instead of file ModTime.
- Unit tests: All passing
- Race detector: Clean (
go test -race ./...) - E2E tests: 16/16 passing
- All 40 bugs fixed — 0 remaining.
- H6 (High): Resume fails for sentinel-paused sessions (PID=0).
enforcer.Resume()required a valid PID for SIGCONT, making sentinel-paused sessions permanently stuck. Fixed: Resume now works without PID — removes sentinel and marks session active. - E2E test suite expanded: 19 tests (was 16). Added Test 9: resume flow for sentinel-paused sessions.
- Verified against real Claude Code sessions: Cost tracking ($0.09 real session), session discovery, StartedAt from JSONL timestamps all confirmed working on live sessions.
- H7 (High):
sendSignalsends SIGSTOP to entire process group (-pgid), which freezes the daemon itself when daemon and agent share a process group (same shell session). Fixed: compare target pgid againstos.Getpgrp()and fall back to single-PID signal when they match. - Live SIGSTOP verified: Real process stopped (state T), SIGCONT resumed it, budget re-triggered immediately (correct behavior — budget limit still in effect).
- LTF traces verified: Proper JSONL with ltf_version, loop_id, agent, action, cost_usd, result, metadata.
- History verified: 42 entries, proper JSONL format with timestamps.
- Adversarial testing verified: Malformed JSONL (binary garbage, empty files, 2MB lines, partial JSON), negative/zero/huge config values, 5 rapid start/stop cycles — no panics, no crashes.
- Codex parser end-to-end: Codex session discovered, parsed with CodexParser, cost tracked ($0.07 via gpt-4.1 pricing), spin detection triggered — all verified.
- CLI edge cases: status offline, resume no-args, resume offline, version, help, config init, config init duplicate, unknown subcommand, stop without service — all return correct errors/output.
- Stale socket recovery: Daemon starts despite stale socket file.
- Dual daemon prevention: Second daemon launch rejected with "already running".
- E2E test suite expanded: 26 tests (was 19). Added stale socket, dual daemon, Codex parser, CLI error handling.
- Unit tests: all pass
- Race detector: clean (
go test -race ./...) - Go vet: clean
- E2E tests: 26/26 passing
- Adversarial testing: no crashes on malformed inputs
- Live enforcement: Real SIGSTOP/SIGCONT verified on live process
- LTF traces + history: Proper JSONL output verified
- Real Claude Code sessions: Cost tracking confirmed on live sessions
- File:
internal/watcher/watcher.go:72-85 - Status: CONFIRMED | FIXED
- Description: The
isRelevant()check was called BEFORE the directory Create handler. Since directory names don't end in.jsonl, isRelevant returned false, skipping the directory add. New directories created after daemon startup were never watched by fsnotify. - Impact: Sessions created in new project directories after daemon start were invisible until the 5s polling fallback kicked in — or never if no tailer existed for the file.
- Fix: Moved directory Create check before the isRelevant filter.
- File:
internal/cli/root.go:71 - Status: CONFIRMED | FIXED
- Description:
runDaemon()callsd.Run()and returns, but never callsd.Shutdown(). Goroutines leak, LTF file handles leak, watcher is never closed. - Impact: Resource leak on every foreground run. Buffered LTF data lost.
- Fix: Added
defer d.Shutdown()after daemon creation.
- File:
internal/daemon/daemon.go:142 - Status: CONFIRMED | FIXED
- Description:
Shutdown()callsanalyzer.Stop()(closes done channel) befored.cancel(). During this window,processEvents()can still callanalyzer.Process()→emit(), which seesdoneclosed and drops alerts silently. - Impact: Alerts lost during shutdown window.
- Fix: Reordered:
cancel()first, thenwg.Wait(), thenanalyzer.Stop(), then cleanup.
- File:
internal/daemon/daemon.go:229-260 - Status: CONFIRMED | FIXED
- Description: Sessions created after initial discovery were not added to the registry until the 60s rediscovery loop ran. The watcher processed their events and the analyzer detected issues, but
executeAlert()couldn't find them in the registry, so all enforcement was skipped. - Impact: New sessions were completely unprotected for up to 60 seconds.
- Fix: Auto-register sessions in
processEvents()when the watcher emits events for an unknown session ID.
- File:
internal/analyzer/budget.go:72-90 - Status: CONFIRMED | FIXED
- Description:
RecordCost()returns the first non-nilBudgetResult. If per-session is at 85% (warning) but per-hour is at 110% (exceeded), only the warning is returned. The exceeded hourly budget is silently ignored. - Impact: Session keeps spending past hard limits because the exceeded status is never surfaced.
- Fix: Collect all results, return the most severe (exceeded > warning).
- File:
internal/analyzer/cost.go:39 - Status: CONFIRMED | FIXED
- Description:
resolve()iterates a Go map for prefix matching. Map iteration order is random, so "gpt-4.1-mini-2025" could match either "gpt-4.1" ($2/$8) or "gpt-4.1-mini" ($0.40/$1.60) — a 5x pricing error. - Impact: Silently wrong cost calculations, budget thresholds crossed at wrong times.
- Fix: Sort keys by length descending, match longest prefix first.
- File:
internal/enforcer/enforcer.go:108-118 - Status: CONFIRMED | FIXED
- Description: After SIGTERM, a goroutine waits 5s then calls
validatePID()andkillProcess()separately. Between validation and kill, the PID could be recycled to an innocent process. - Impact: Could SIGKILL an unrelated process (low probability but catastrophic).
- Fix: Capture pgid before spawning goroutine; verify process start time before SIGKILL.
- File:
internal/discovery/custom.go:39-46 - Status: CONFIRMED | FIXED
- Description: When
os.UserHomeDir()fails,homeis empty. The checkhome != "" && !HasPrefix(...)short-circuits to false, allowing any path. - Impact: In containers/cron/systemd, the home-dir restriction is silently disabled.
- Fix: Reject all custom paths when home dir is unavailable.
- File:
internal/discovery/claude.go:85-87 - Status: CONFIRMED | FIXED
- Description: All hyphens become path separators. "/my-project" → "/my/project". Documented as lossy but impacts sentinel file placement.
- Fix:
readSessionMeta()extracts thecwdfield from the JSONL file (first 20 lines). When present, it overrides the lossy decoded path. Verified on real sessions:/Users/rfirke/Downloads/AI Projects/Loop Engineeringnow shows correctly.
- File:
internal/parser/codex.go:56,70,87 - Status: CONFIRMED | FIXED
- Description:
json.Unmarshal(entry.Data, &tc)errors are discarded. Malformed events are silently swallowed. - Fix: Return errors from unmarshal failures.
- File:
internal/analyzer/budget.go:67,internal/analyzer/spin.go:215 - Status: CONFIRMED | FIXED
- Description: Re-slicing
be.hourlyCosts = be.hourlyCosts[trimIdx:]doesn't release the backing array. - Fix: Copy to new slice when trimIdx > 0.
- File:
internal/parser/claude.go:85-86 - Status: CONFIRMED | FIXED
- Description: Full map reset means previously-seen requestIds are forgotten, causing tokens to be counted twice.
- Fix: Evict oldest half of entries instead of full reset.
- File:
internal/watcher/watcher.go:167-175 - Status: CONFIRMED | FIXED
- Description: Check-then-create for tailers is not atomic. Two debouncer callbacks could create duplicate tailers.
- Fix: Hold the lock across the check-and-create.
- File:
internal/daemon/daemon.go:317 - Status: CONFIRMED | FIXED
- Description: If
enforcer.Execute(ActionKill, ...)fails, the session is still marked inactive and notifications sent as if it succeeded. - Fix: Check error, log it, and only mark inactive if kill succeeded.
- File:
internal/analyzer/analyzer.go:165 - Status: CONFIRMED | FIXED
- Description:
close(a.done)on an already-closed channel panics. No sync.Once guard. - Fix: Added sync.Once to guard the close.
- File:
internal/analyzer/analyzer.go:49,52 - Status: CONFIRMED | FIXED
- Description: No mechanism to remove entries from a.sessions or a.warned when sessions end.
- Fix: Added
RemoveSession()to Analyzer and BudgetEnforcer.
- File:
internal/daemon/daemon.go:110-114 - Status: CONFIRMED | FIXED
- Description: API server goroutine not added to wg, so Shutdown() doesn't wait for it.
- Fix: Added wg.Add(1) and defer wg.Done().
- File:
internal/daemon/daemon.go:329,internal/api/server.go:77 - Status: CONFIRMED | FIXED
- Description: Two independent implementations of socket path logic. Could diverge.
- Fix: Daemon uses api.SocketPath(). Removed daemon.socketPath().
- File:
internal/daemon/daemon.go:246,internal/discovery/discovery.go:39 - Status: CONFIRMED | FIXED
- Description:
processEventsauto-registers unknown sessions viaregistry.Add, which unconditionally overwrites. IfrediscoveryLoopadds a properly-discovered session (with PID) between theGetandAddcalls, the proper session is overwritten with a PID-less auto-registered one. - Fix: Added
Registry.TryAdd()that only adds if session doesn't already exist.
- File:
internal/api/handlers.go:57 - Status: CONFIRMED | FIXED
- Description: "session not found" returns 400 instead of 404.
- Fix: Returns HTTP 404 when error message contains "not found".
- File:
internal/config/config.go:100-108 - Status: CONFIRMED | FIXED
- Description:
fmt.Sscanferrors discarded. User setsLOOPGUARD_BUDGET_PER_SESSION=abc, gets no feedback. - Fix: Use strconv.ParseFloat, log warning on parse failure.
- File:
internal/config/config.go:91 - Status: CONFIRMED | FIXED
- Description: A bare
budget:key with no values zeros all budget fields, potentially disabling safety limits. - Fix:
applyDefaults()restores structural defaults (spin thresholds, paths, log level) while allowing budget values of 0 to mean "disabled".
- File:
internal/cli/client.go:14 - Status: CONFIRMED | FIXED
- Description: CLI commands hang forever if daemon is unresponsive.
- Fix: Added 10s timeout.
- File:
internal/enforcer/enforcer.go:108 - Status: CONFIRMED | FIXED
- Description: If group leader dies but children survive, SIGKILL on leader PID fails, leaving orphans.
- Fix: SIGKILL follow-up now targets
-pgid(entire process group) instead of individual PID.
- File:
internal/enforcer/sentinel.go:11 - Status: CONFIRMED | FIXED
- Description:
os.WriteFilefollows symlinks. Attacker could redirect sentinel write to overwrite arbitrary files. - Fix: Added Lstat check before WriteFile.
- File:
internal/discovery/custom.go:70 - Status: CONFIRMED | FIXED
- Description: No liveness check for custom sessions. Stale files reported as active.
- Fix: Custom sessions use
lsofto check if a process has the JSONL file open.
- File:
internal/discovery/custom.go:28-32 - Status: CONFIRMED | FIXED
- Description: Only the first custom path gets fsnotify watching.
- Fix: Each custom path creates a separate CustomDiscoverer instance, each returning its own BasePath for watching.
- File:
internal/discovery/codex.go:60 - Status: CONFIRMED | FIXED
- Description: ProjectDir is never set for codex sessions, breaking sentinel fallback.
- Fix: Codex sessions set ProjectDir from the session path directory.
- File:
internal/cli/resume.go:27-30 - Status: CONFIRMED | FIXED
- Description: First prefix match wins. If multiple sessions share a prefix, wrong one is resumed.
- Fix: Collects all matches; rejects ambiguous prefixes with a user-facing error listing the matches.
- File:
internal/daemon/history.go:45,internal/ltf/emitter.go:148 - Status: CONFIRMED | FIXED
- Description: Lstat check then OpenFile has a race window for symlink swap.
- Fix: History file opened with
O_NOFOLLOWsyscall flag, eliminating the TOCTOU window.
- File:
internal/api/server.go:59 - Status: CONFIRMED | FIXED
- Description: A misbehaving client could exhaust file descriptors.
- Fix: Added ReadTimeout: 10s, WriteTimeout: 10s, IdleTimeout: 60s.
- File:
internal/cli/status.go:51 - Status: CONFIRMED | FIXED
- Description:
status := "running"shadows outer*api.StatusResponse. - Fix: Renamed inner variable to
state.
- File:
internal/discovery/claude.go:74 - Status: CONFIRMED | FIXED
- Description: ModTime reflects last write, not session creation time.
- Fix: Reads the timestamp from the first JSONL line; falls back to ModTime if parse fails.
- File:
internal/notify/notify.go:62 - Status: CONFIRMED | FIXED
- Description: Go's %q produces \u escapes for non-ASCII; AppleScript may not parse them.
- Fix: Replaced
%qwithescapeAppleScript()that only escapes backslashes and double quotes, passing UTF-8 through literally.