Fixing tmux Local Network Access on macOS
The Problem
On macOS Tahoe (and potentially earlier versions), an insidious networking issue affects unsigned third-party tools running from within unsigned tools like tmux (installed from Homebrew): connections to local network addresses (like 192.168.x.x) are silently blocked with a cryptic “no route to host” error.
My Environment
- macOS Version: Tahoe 26.0.1
- tmux: 3.5a (installed via Homebrew)
- Go: 1.24.6 (installed via Homebrew)
- curl:
/usr/bin/curl(system binary)
What’s Actually Happening
The problem is deceptively selective that I am still wrapping my head around from my frustration:
# Run directly in Terminal.app (NOT in tmux or screen)
$ which go
/opt/homebrew/bin/go # Third-party Homebrew - unsigned
$ go run my_app.go # Connects to 192.168.50.2
SUCCESS: Connected # ✅ Works! Even though go is unsigned Homebrew!
# Run from inside screen (system binary at /usr/bin/screen)
$ which screen
/usr/bin/screen # System binary - signed by Apple
$ screen
$ go run my_app.go # Same app, same unsigned go
SUCCESS: Connected # ✅ Still works!
# Now from inside tmux session (unsigned Homebrew)
$ which curl
/usr/bin/curl # System binary - signed by Apple
$ curl https://192.168.50.2
HTTP/1.1 200 OK # ✅ Still works!
$ go run my_app.go # Same app, same 192.168.50.2
ERROR: no route to host # ❌ NOW BLOCKED!
$ node my_script.js # Same script, same 192.168.50.2
ERROR: ECONNREFUSED # ❌ NOW BLOCKED!
The maddening part: curl shows the server is reachable, but your Go apps, Node apps, and AI coding CLIs mysteriously fail to connect to the same IP address.
This creates a deeply confusing situation:
- The network is clearly working (curl succeeds)
- The server is reachable (curl confirms it)
- But your application fails with “no route to host”
What seems to be happening: When you run scripts or programs from within tmux (unsigned Homebrew tool), those scripts are executed by interpreters/runtimes (node, python - also unsigned Homebrew tools), or compiled into unsigned executable by unsigned compiler toolchain (go), creating a chain of unsigned tools. The observed behavior suggests macOS blocks local network access when this toolchain consists of more than 1 hop of unsigned binaries, though I’m not certain of the exact logic involved.
The million dollar question this raises: How do you even know these apps are connecting directly to the IP you specified? Could they be secretly routing through a cloud proxy and failing because private IPs aren’t routable through their infrastructure? The error message provides no indication that this is a macOS permission issue rather than a networking or application architecture problem.
This particularly scares people when they are setting up AI coding assistants and development tools, that for privacy reason you want it to only connect to local AI model running on your private network.
Root Cause: Unsigned Binary from Unsigned Parent
macOS enforces Local Network Privacy restrictions. Based on observed behavior, it appears to check both the application making the connection AND its parent process, though the exact logic remains unclear to me.
$ which curl
/usr/bin/curl # System binary (Apple-signed) → ✅ Exempt
$ which go
/opt/homebrew/bin/go # Third-party Homebrew binary → ⚠️ Checked by macOS
$ which node
/opt/homebrew/bin/node # Third-party Homebrew binary → ⚠️ Checked by macOS
The critical observation: Unsigned tools (go, node) work fine when run directly from Terminal.app:
Terminal.app (system app - signed)
└─ go run my_app.go
└─ go (Homebrew - UNSIGNED)
→ ✅ WORKS!
They also work through system binaries like screen:
Terminal.app (system app - signed)
└─ screen (system binary - /usr/bin/screen - signed)
└─ go run my_app.go
└─ go (Homebrew - UNSIGNED)
→ ✅ WORKS!
But the SAME unsigned tools fail when spawned from unsigned tmux:
Terminal.app (system app - signed)
└─ tmux (Homebrew - UNSIGNED)
└─ go run my_app.go
└─ go (Homebrew - UNSIGNED)
└─ compiled binary tries TCP connection to 192.168.x.x → ❌ BLOCKED!
Same with Node.js:
# Works directly from Terminal.app:
Terminal.app (signed)
└─ node my_script.js (UNSIGNED) → ✅ WORKS!
# Fails from within tmux:
Terminal.app (signed)
└─ tmux (UNSIGNED)
└─ node my_script.js (UNSIGNED) → ❌ BLOCKED!
And system tools always work:
Terminal.app (signed)
└─ tmux (UNSIGNED)
└─ curl (system binary - SIGNED)
→ ✅ WORKS
My best guess: Based on testing, it appears that macOS checks the entire parent chain and permits unsigned tools to access local networks only if ALL parents in the chain are signed. Examples:
- Terminal.app (signed) → go (unsigned) = ✅ Works
- Terminal.app (signed) → screen (signed) → go (unsigned) = ✅ Works
- Terminal.app (signed) → tmux (unsigned) → go (unsigned) = ❌ Blocked
System binaries (curl, ssh) work regardless because they’re signed by Apple themselves. However, I don’t know the exact logic macOS uses internally - this is simply what the observed behavior suggests.
Why This is Particularly Bad
This behavior creates a serious transparency and trust problem:
No Clear Error Message: “no route to host” suggests a networking/routing problem, not a permissions issue. There’s no indication that macOS is actively blocking the connection.
Inconsistent Behavior: System tools (curl, ssh) work fine, but third-party tools fail. This makes debugging nearly impossible without deep system knowledge.
Trust Erosion: When an application you downloaded from the internet to read and process your personal data fails to connect to a local IP, you’re forced to question: “Is this app secretly proxying my data through their cloud service? Why wouldn’t it be able to reach a private IP on my network?”
Developer Friction: I only came to the conclusion that the application could be trusted because: a) they are open source, b) I examined their source code and found no suspicious business in making TCP connections - just straightforward network calls, and c) I had compiled one of the apps myself personally for entirely different reasons. This allowed me to rule out the “secret cloud proxy” theory and eventually identify the actual macOS permissions issue. Imagine hours spent debugging this from potential users.
Apple’s implementation of Local Network Privacy, while well-intentioned, well, just sucks.
The Solution: Embed Info.plist
The fix involves embedding an Info.plist file directly into the tmux binary during compilation. This provides macOS with:
- A proper bundle identifier
- A local network usage description
- Information needed to track permissions
Implementation
Step 1: Create Info.plist
Create an Info.plist file in your tmux source directory:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.github.tmux</string>
<key>CFBundleName</key>
<string>tmux</string>
<key>CFBundleVersion</key>
<string>3.5a</string>
<key>NSLocalNetworkUsageDescription</key>
<string>tmux needs access to the local network to manage terminal sessions.</string>
</dict>
</plist>
Key fields:
CFBundleIdentifier: Unique identifier for macOS to track permissionsNSLocalNetworkUsageDescription: Critical - Describes why local network access is needed
Step 2: Build with Embedded Plist
The magic happens in the linker flags. Use the -Wl,-sectcreate flag to embed the Info.plist into the __TEXT segment of the binary:
./configure --disable-utf8proc \
LDFLAGS="-Wl,-sectcreate,__TEXT,__info_plist,Info.plist"
make clean
make
What this does:
-Wl,- Pass the following arguments to the linker-sectcreate- Create a new section in the binary__TEXT- Place it in the TEXT segment__info_plist- Name the section__info_plist(what macOS looks for)Info.plist- Use this file as the section content
Step 3: Code Sign
codesign -s - -f ./tmux
Step 4: Install
sudo cp ./tmux /opt/homebrew/Cellar/tmux/3.5a/bin/tmux
Adjust the path based on your tmux version.
Step 5: Restart tmux Server
Critical step: tmux keeps the old binary in memory. You must restart the server:
tmux kill-server
tmux new -s test
Detaching from sessions is not enough - you must kill the server to reload the binary from disk.
Automated Build Script
Save this as build-with-plist.sh:
#!/bin/bash
set -e
echo "=== Building tmux with embedded Info.plist ==="
# Clean previous build
make clean 2>/dev/null || true
# Reconfigure with Info.plist linker flag
./configure --disable-utf8proc \
LDFLAGS="-Wl,-sectcreate,__TEXT,__info_plist,Info.plist"
# Build
make
# Verify Info.plist is embedded
echo ""
echo "=== Verifying Info.plist embedding ==="
if otool -l ./tmux | grep -q "__info_plist"; then
echo "✓ Info.plist found in binary!"
echo ""
segedit ./tmux -extract __TEXT __info_plist /tmp/tmux-plist.txt 2>/dev/null || true
cat /tmp/tmux-plist.txt
else
echo "✗ Info.plist NOT embedded"
exit 1
fi
# Sign it
echo ""
echo "=== Signing binary ==="
codesign -s - -f ./tmux
# Install over Homebrew version
echo ""
echo "=== Installing to /opt/homebrew/bin/tmux ==="
sudo cp ./tmux /opt/homebrew/bin/tmux
echo ""
echo "✓ SUCCESS! tmux rebuilt with Info.plist and installed"
echo ""
echo "Test it in Terminal.app:"
echo " tmux kill-server"
echo " tmux new -s test"
Make it executable and run:
chmod +x build-with-plist.sh
./build-with-plist.sh
Verification
Check if Info.plist is Embedded
otool -l /opt/homebrew/bin/tmux | grep "__info_plist"
Expected output:
sectname __info_plist
segname __TEXT
Extract and View Embedded Plist
segedit /opt/homebrew/bin/tmux -extract __TEXT __info_plist /tmp/extracted.plist
cat /tmp/extracted.plist
Should display your Info.plist content.
Verify Code Signature
codesign -dvvv /opt/homebrew/bin/tmux 2>&1 | grep -E "Identifier=|Info.plist"
Expected output:
Identifier=com.github.tmux
Info.plist entries=4
Testing
Test Script
Create test_network.go to verify local network access:
package main
import (
"fmt"
"net"
"net/http"
"time"
)
func main() {
// Test 1: Raw TCP connection to local network
fmt.Println("Test 1: TCP connection to 192.168.50.2:443")
conn, err := net.DialTimeout("tcp", "192.168.50.2:443", 5*time.Second)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" SUCCESS: Connected\n")
conn.Close()
}
// Test 2: HTTP GET to local network
fmt.Println("\nTest 2: HTTP GET to local server")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://192.168.50.2")
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" SUCCESS: HTTP %d\n", resp.StatusCode)
resp.Body.Close()
}
}
Before Fix (Blocked)
# Inside tmux (before embedding Info.plist)
$ go run test_network.go
Test 1: TCP connection to 192.168.50.2:443
ERROR: dial tcp 192.168.50.2:443: connect: no route to host
Test 2: HTTP GET to local server
ERROR: Get "https://192.168.50.2": dial tcp 192.168.50.2:443: connect: no route to host
After Fix (Working)
# Inside tmux (after embedding Info.plist and restarting server)
$ tmux kill-server
$ tmux new -s test
$ go run test_network.go
Test 1: TCP connection to 192.168.50.2:443
SUCCESS: Connected
Test 2: HTTP GET to local server
SUCCESS: HTTP 200
Key Takeaways
What I’ve Observed
- Unsigned tools work from Terminal.app: Running
go run my_app.goornode my_script.jsdirectly from Terminal.app works perfectly, even though go and node are unsigned Homebrew binaries - Unsigned tools also work through system screen: The same unsigned tools work fine when run inside
screen(system binary at/usr/bin/screen) - Same unsigned tools fail from unsigned tmux: The exact same commands fail with “no route to host” when run inside tmux (unsigned Homebrew binary)
- System binaries always work:
/usr/bin/curl,/usr/bin/sshwork fine everywhere - directly from Terminal.app, from within screen, AND from within unsigned tmux - Signed intermediaries appear to “pass through” permissions: It appears that as long as the chain includes only signed binaries (Terminal.app → screen → go), unsigned tools can access local networks. But if ANY link in the chain is unsigned (Terminal.app → tmux → go), the access is blocked
- Confusing diagnostics: curl works (system binary), screen+go works, but the same go/node apps that work everywhere else mysteriously fail in tmux with misleading “no route to host” errors
The Fix in Three Steps
- Create
Info.plistwithNSLocalNetworkUsageDescription - Rebuild with
-Wl,-sectcreate,__TEXT,__info_plist,Info.plist - Restart tmux server (not just detach!)
Conclusion
macOS Local Network Privacy restrictions affect third-party command-line tools in unexpected ways. By embedding an Info.plist directly into the binary, we transform tmux from an “unknown binary” into a properly identified application, allowing it to request and receive local network access permissions like any other macOS app.
References
- Apple Documentation: NSLocalNetworkUsageDescription
- Apple Code Signing Guide
- Linker documentation:
man ld(search for “sectcreate”)