8.9 KiB
Architecture
Fence restricts network and filesystem access for arbitrary commands. It works by:
- Intercepting network traffic via HTTP/SOCKS5 proxies that filter by domain
- Sandboxing processes using OS-native mechanisms (macOS sandbox-exec, Linux bubblewrap)
- Bridging connections to allow controlled inbound/outbound traffic in isolated namespaces
flowchart TB
subgraph Fence
Config["Config<br/>(JSON)"]
Manager
Sandbox["Platform Sandbox<br/>(macOS/Linux)"]
HTTP["HTTP Proxy<br/>(filtering)"]
SOCKS["SOCKS5 Proxy<br/>(filtering)"]
end
Config --> Manager
Manager --> Sandbox
Manager --> HTTP
Manager --> SOCKS
Project Structure
fence/
├── cmd/fence/ # CLI entry point
│ └── main.go
├── internal/ # Private implementation
│ ├── config/ # Configuration loading/validation
│ ├── platform/ # OS detection
│ ├── proxy/ # HTTP and SOCKS5 filtering proxies
│ └── sandbox/ # Platform-specific sandboxing
│ ├── manager.go # Orchestrates sandbox lifecycle
│ ├── macos.go # macOS sandbox-exec profiles
│ ├── linux.go # Linux bubblewrap + socat bridges
│ ├── monitor.go # macOS log stream violation monitoring
│ ├── dangerous.go # Protected file/directory lists
│ └── utils.go # Path normalization, shell quoting
└── pkg/fence/ # Public Go API
└── fence.go
Core Components
Config (internal/config/)
Handles loading and validating sandbox configuration:
type Config struct {
Network NetworkConfig // Domain allow/deny lists
Filesystem FilesystemConfig // Read/write restrictions
AllowPty bool // Allow pseudo-terminal allocation
}
- Loads from
~/.fence.jsonor custom path - Falls back to restrictive defaults (block all network)
- Validates paths and normalizes them
Platform (internal/platform/)
Simple OS detection:
func Detect() Platform // Returns MacOS, Linux, Windows, or Unknown
func IsSupported() bool // True for MacOS and Linux
Proxy (internal/proxy/)
Two proxy servers that filter traffic by domain:
HTTP Proxy (http.go)
- Handles HTTP and HTTPS (via CONNECT tunneling)
- Extracts domain from Host header or CONNECT request
- Returns 403 for blocked domains
- Listens on random available port
SOCKS5 Proxy (socks.go)
- Uses
github.com/things-go/go-socks5 - Handles TCP connections (git, ssh, etc.)
- Same domain filtering logic as HTTP proxy
- Listens on random available port
Domain Matching:
- Exact match:
example.com - Wildcard prefix:
*.example.com(matchesapi.example.com) - Deny takes precedence over allow
Sandbox (internal/sandbox/)
Manager (manager.go)
Orchestrates the sandbox lifecycle:
- Initializes HTTP and SOCKS proxies
- Sets up platform-specific bridges (Linux)
- Wraps commands with sandbox restrictions
- Handles cleanup on exit
macOS Implementation (macos.go)
Uses Apple's sandbox-exec with Seatbelt profiles:
flowchart LR
subgraph macOS Sandbox
CMD["User Command"]
SE["sandbox-exec -p profile"]
ENV["Environment Variables<br/>HTTP_PROXY, HTTPS_PROXY<br/>ALL_PROXY, GIT_SSH_COMMAND"]
end
subgraph Profile Controls
NET["Network: deny except localhost"]
FS["Filesystem: read/write rules"]
PROC["Process: fork/exec permissions"]
end
CMD --> SE
SE --> ENV
SE -.-> NET
SE -.-> FS
SE -.-> PROC
Seatbelt profiles are generated dynamically based on config:
(deny default)- deny all by default(allow network-outbound (remote ip "localhost:*"))- only allow proxy(allow file-read* ...)- selective file access(allow process-fork),(allow process-exec)- allow running programs
Linux Implementation (linux.go)
Uses bubblewrap (bwrap) with network namespace isolation:
flowchart TB
subgraph Host
HTTP["HTTP Proxy<br/>:random"]
SOCKS["SOCKS Proxy<br/>:random"]
HSOCAT["socat<br/>(HTTP bridge)"]
SSOCAT["socat<br/>(SOCKS bridge)"]
USOCK["Unix Sockets<br/>/tmp/fence-*.sock"]
end
subgraph Sandbox ["Sandbox (bwrap --unshare-net)"]
CMD["User Command"]
ISOCAT["socat :3128"]
ISOCKS["socat :1080"]
ENV2["HTTP_PROXY=127.0.0.1:3128"]
end
HTTP <--> HSOCAT
SOCKS <--> SSOCAT
HSOCAT <--> USOCK
SSOCAT <--> USOCK
USOCK <-->|bind-mounted| ISOCAT
USOCK <-->|bind-mounted| ISOCKS
CMD --> ISOCAT
CMD --> ISOCKS
CMD -.-> ENV2
Why socat bridges?
With --unshare-net, the sandbox has its own isolated network namespace - it cannot reach the host's network at all. Unix sockets provide filesystem-based IPC that works across namespace boundaries:
- Host creates Unix socket, connects to TCP proxy
- Socket file is bind-mounted into sandbox
- Sandbox's socat listens on localhost:3128, forwards to Unix socket
- Traffic flows:
sandbox:3128 → Unix socket → host proxy → internet
Inbound Connections (Reverse Bridge)
For servers running inside the sandbox that need to accept connections:
flowchart TB
EXT["External Request"]
subgraph Host
HSOCAT["socat<br/>TCP-LISTEN:8888"]
USOCK["Unix Socket<br/>/tmp/fence-rev-8888-*.sock"]
end
subgraph Sandbox
ISOCAT["socat<br/>UNIX-LISTEN"]
APP["App Server<br/>:8888"]
end
EXT --> HSOCAT
HSOCAT -->|UNIX-CONNECT| USOCK
USOCK <-->|shared via bind /| ISOCAT
ISOCAT --> APP
Flow:
- Host socat listens on TCP port (e.g., 8888)
- Sandbox socat creates Unix socket, forwards to app
- External request → Host:8888 → Unix socket → Sandbox socat → App:8888
Execution Flow
flowchart TD
A["1. CLI parses arguments"] --> B["2. Load config from ~/.fence.json"]
B --> C["3. Create Manager"]
C --> D["4. Manager.Initialize()"]
D --> D1["Start HTTP proxy"]
D --> D2["Start SOCKS proxy"]
D --> D3["[Linux] Create socat bridges"]
D --> D4["[Linux] Create reverse bridges"]
D1 & D2 & D3 & D4 --> E["5. Manager.WrapCommand()"]
E --> E1["[macOS] Generate Seatbelt profile"]
E --> E2["[Linux] Generate bwrap command"]
E1 & E2 --> F["6. Execute wrapped command"]
F --> G["7. Manager.Cleanup()"]
G --> G1["Kill socat processes"]
G --> G2["Remove Unix sockets"]
G --> G3["Stop proxy servers"]
Platform Comparison
| Feature | macOS | Linux |
|---|---|---|
| Sandbox mechanism | sandbox-exec (Seatbelt) | bubblewrap (namespaces) |
| Network isolation | Syscall filtering | Network namespace |
| Proxy routing | Environment variables | socat bridges + env vars |
| Filesystem control | Profile rules | Bind mounts |
| Inbound connections | Profile rules (network-bind) |
Reverse socat bridges |
| Violation monitoring | log stream + proxy | proxy only |
| Requirements | Built-in | bwrap, socat |
Violation Monitoring
The -m (monitor) flag enables real-time visibility into blocked operations.
Output Prefixes
| Prefix | Source | Description |
|---|---|---|
[fence:http] |
Both | HTTP/HTTPS proxy (blocked requests only in monitor mode) |
[fence:socks] |
Both | SOCKS5 proxy (blocked requests only in monitor mode) |
[fence:logstream] |
macOS only | Kernel-level sandbox violations from log stream |
[fence:filter] |
Both | Domain filter rule matches (debug mode only) |
macOS Log Stream
On macOS, fence spawns log stream with a predicate to capture sandbox violations:
log stream --predicate 'eventMessage ENDSWITH "_SBX"' --style compact
Violations include:
network-outbound- blocked network connectionsfile-read*- blocked file readsfile-write*- blocked file writes
Filtered out (too noisy):
mach-lookup- IPC service lookupsfile-ioctl- device control operations/dev/tty*writes - terminal outputmDNSResponder- system DNS resolution/private/var/run/syslog- system logging
Linux Limitations
Linux uses network namespace isolation (--unshare-net), which prevents connections at the namespace level rather than logging them. There's no kernel-level violation stream equivalent to macOS.
With -m on Linux, you only see proxy-level denials:
[fence:http] 14:30:01 ✗ CONNECT 403 evil.com https://evil.com:443 (0s)
[fence:socks] 14:30:02 ✗ CONNECT evil.com:22 BLOCKED
Debug vs Monitor Mode
| Flag | Proxy logs | Filter rules | Log stream | Sandbox command |
|---|---|---|---|---|
-m |
Blocked only | No | Yes (macOS) | No |
-d |
All | Yes | No | Yes |
-m -d |
All | Yes | Yes (macOS) | Yes |
Security Model
See docs/security-model.md for Fence's threat model, guarantees, and limitations.