
CLI Snitch monitors every outbound connection from your Mac and lets you allow or deny them with real pfctl firewall rules — all from the terminal.
I wanted to know what my Mac was doing behind my back.
Every app phones home. Electron apps ping telemetry endpoints. Browsers hit trackers. Even system processes make connections you never asked for. Little Snitch shows you all of this beautifully — it's a proper, polished product and well worth the money.
This is not a replacement for Little Snitch. This is what happens when you're curious about network monitoring and want to see how far you can get with Go, lsof, and pfctl in a terminal. Think of it as a learning project that accidentally became useful.
CLI Snitch watches every outbound TCP and UDP connection, prompts you to allow or deny, and enforces your decisions with real macOS firewall rules — all from the terminal.
$ sudo cli-snitch watch
🚨 New Outbound Connection Detected
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📱 Application: Electron Helper
🌐 Destination: telemetry.example.com:443
🔌 Protocol: TCP
🏷️ Host Info: Amazon Web Services (AWS)
? What would you like to do?
✅ Allow Once
🔁 Allow Always
❌ Deny Once
> 🚫 Deny Always
❌ Electron Helper DENIED -> telemetry.example.com:443
🔥 Firewall rule: block out proto tcp from any to telemetry.example.com port 443
🛡️ Firewall rule applied
That last line is real. It's not a log message — it's an actual pfctl rule injected into the macOS packet filter. The connection is blocked at the kernel level.
The architecture is straightforward:
lsof -i tcp -i udp -n every 2 seconds (adaptive intervals)pfctl anchor rules that survive the sessionThe interesting engineering problems were:
Multiple connections can arrive within the same 2-second scan. If two prompts hit stdin simultaneously, everything breaks. I solved this with a buffered channel queue:
type promptRequest struct {
conn *monitor.Connection
resultCh chan promptResult
}
func (cp *ConnectionPrompter) QueuePrompt(conn *Connection) (*UserDecision, error) {
req := &promptRequest{
conn: conn,
resultCh: make(chan promptResult, 1),
}
cp.promptQueue <- req
res := <-req.resultCh
return res.decision, res.err
}
A single goroutine reads from the queue and calls the survey prompt — one at a time, no collisions.
When a user denies a connection, the host and port values end up in a pfctl command. If I naively concatenated those strings, a malformed hostname could inject commands. Every value is validated against regex before it touches pfctl:
var hostPattern = regexp.MustCompile(`^[a-zA-Z0-9._:\-]+$`)
var portPattern = regexp.MustCompile(`^[0-9]+$`)
lsof reports all active connections every scan, not just new ones. The monitor maintains a map of seen connections plus a TTL-based "recent cache" — so connections that get cleaned up from the main map but reappear within 5 minutes don't re-trigger prompts.
14 commands:
watch Real-time monitoring (the main event)
list-rules See all your allow/deny rules
edit-rule Modify a rule inline
import-rules Load rules from JSON
export-rules Back up rules to JSON
history View connection log with filters
firewall-status Check pfctl integration
list-firewall Show active blocking rules
clear-firewall Remove all pfctl rules
firewall-cleanup Remove expired temp rules
firewall-monitor Live firewall status
system-status Full diagnostics
daemon install Set up as a launchd service
daemon start/stop Control the background service
Some features I'm particularly happy with:
*.analytics.com blocks all analytics subdomainssudo cli-snitch daemon install creates a launchd plist for background monitoringbrew tap nickciolpan/tap
brew install cli-snitch
sudo cli-snitch watch
Or build from source:
git clone https://github.com/nickciolpan/snitcher
cd snitcher
go build -o cli-snitch ./cmd/cli-snitch
sudo ./cli-snitch watch
Being honest about the limitations:
Google Chrome Helper becomes Google, Slack becomes Slack\x20. Works fine once you know the quirk.lsof is surprisingly good for this use case. It's fast, available everywhere, and the output is parseable. The main gotcha is IPv6 bracket notation and process name encoding.
pfctl anchors are the right abstraction. By isolating all CLI Snitch rules in a named anchor, there's zero risk of clobbering system firewall rules. Cleanup is just "reload an empty anchor file."
Interactive CLI tools need careful goroutine design. The prompt queue pattern — buffered channel + single-reader goroutine — is something I'll reuse in every interactive CLI from now on.
The source is at github.com/nickciolpan/snitcher and the docs are at cli-snitch.ciolpan.com. MIT licensed.
If you've ever wondered what your Mac is doing when you're not looking, give it a try. You might be surprised.
Yes, this was written with the help of an LLM. The code too. Are we still pretending that's not how things get built in 2026? Claude wrote most of the implementation, I steered, tested, broke things, and made the decisions. The architecture is real, the bugs were real, and the pfctl rules definitely blocked my Chrome tabs for real. Tools are tools — what matters is whether the thing works. It does.
aiMost of us have seen a coding agent fail to complete a task we know it can do. We just don't...
googlecloudWhen building Generative AI applications, developers often encounter a massive bottleneck: sequential...
discussI’ve been thinking about sharing some electronic circuit posts on Dev.to — small circuits, DIY...
agentsWhat nobody tells you about exporting your multi-agent prototype to a local workspace. Every...
agenticarchitectAutonomous agents are genuinely good at answering messy business questions. Give one an LLM and a set...
aiPR volume went up, ticket quality didn't, and the gap got filled with LLMs on both sides of the review: bots reviewing, bots replying, bots occasionally arguing with bots about priorities that only existed in a teammate's head. Our CEO named the actual problem, and it's bigger than code review.
Workflows from the Neura Market marketplace related to this Stable Diffusion resource