# Architecture Fence restricts network and filesystem access for arbitrary commands. It works by: 1. **Intercepting network traffic** via HTTP/SOCKS5 proxies that filter by domain 2. **Sandboxing processes** using OS-native mechanisms (macOS sandbox-exec, Linux bubblewrap) 3. **Bridging connections** to allow controlled inbound/outbound traffic in isolated namespaces ```mermaid flowchart TB subgraph Fence Config["Config
(JSON)"] Manager Sandbox["Platform Sandbox
(macOS/Linux)"] HTTP["HTTP Proxy
(filtering)"] SOCKS["SOCKS5 Proxy
(filtering)"] end Config --> Manager Manager --> Sandbox Manager --> HTTP Manager --> SOCKS ``` ## Project Structure ```text 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: ```go type Config struct { Network NetworkConfig // Domain allow/deny lists Filesystem FilesystemConfig // Read/write restrictions AllowPty bool // Allow pseudo-terminal allocation } ``` - Loads from `~/.fence.json` or custom path - Falls back to restrictive defaults (block all network) - Validates paths and normalizes them ### Platform (`internal/platform/`) Simple OS detection: ```go 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` (matches `api.example.com`) - Deny takes precedence over allow ### Sandbox (`internal/sandbox/`) #### Manager (`manager.go`) Orchestrates the sandbox lifecycle: 1. Initializes HTTP and SOCKS proxies 2. Sets up platform-specific bridges (Linux) 3. Wraps commands with sandbox restrictions 4. Handles cleanup on exit #### macOS Implementation (`macos.go`) Uses Apple's `sandbox-exec` with Seatbelt profiles: ```mermaid flowchart LR subgraph macOS Sandbox CMD["User Command"] SE["sandbox-exec -p profile"] ENV["Environment Variables
HTTP_PROXY, HTTPS_PROXY
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: ```mermaid flowchart TB subgraph Host HTTP["HTTP Proxy
:random"] SOCKS["SOCKS Proxy
:random"] HSOCAT["socat
(HTTP bridge)"] SSOCAT["socat
(SOCKS bridge)"] USOCK["Unix Sockets
/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: 1. Host creates Unix socket, connects to TCP proxy 2. Socket file is bind-mounted into sandbox 3. Sandbox's socat listens on localhost:3128, forwards to Unix socket 4. Traffic flows: `sandbox:3128 → Unix socket → host proxy → internet` ## Inbound Connections (Reverse Bridge) For servers running inside the sandbox that need to accept connections: ```mermaid flowchart TB EXT["External Request"] subgraph Host HSOCAT["socat
TCP-LISTEN:8888"] USOCK["Unix Socket
/tmp/fence-rev-8888-*.sock"] end subgraph Sandbox ISOCAT["socat
UNIX-LISTEN"] APP["App Server
:8888"] end EXT --> HSOCAT HSOCAT -->|UNIX-CONNECT| USOCK USOCK <-->|shared via bind /| ISOCAT ISOCAT --> APP ``` Flow: 1. Host socat listens on TCP port (e.g., 8888) 2. Sandbox socat creates Unix socket, forwards to app 3. External request → Host:8888 → Unix socket → Sandbox socat → App:8888 ## Execution Flow ```mermaid 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: ```bash log stream --predicate 'eventMessage ENDSWITH "_SBX"' --style compact ``` Violations include: - `network-outbound` - blocked network connections - `file-read*` - blocked file reads - `file-write*` - blocked file writes Filtered out (too noisy): - `mach-lookup` - IPC service lookups - `file-ioctl` - device control operations - `/dev/tty*` writes - terminal output - `mDNSResponder` - 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: ```text [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 ### How Each Layer Works #### Network Isolation All outbound connections are routed through local HTTP/SOCKS5 proxies that filter by domain: - Direct socket connections are blocked at the OS level (syscall filtering on macOS, network namespace on Linux) - Only localhost connections to the proxy ports are allowed - The proxy inspects the target domain and allows/denies based on config - Environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`) route application traffic #### Filesystem Restrictions Access control follows a deny-by-default model for writes: | Operation | Default | Config | |-----------|---------|--------| | Read | Allow all | `denyRead` blocks specific paths | | Write | Deny all | `allowWrite` permits specific paths | | Write exceptions | - | `denyWrite` overrides `allowWrite` | #### Dangerous File Protection Certain paths are always protected from writes regardless of config to prevent common attack vectors: **Protected files:** - Shell configs: `.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile` - Git config: `.gitconfig`, `.gitmodules`, `.git/config` (can define aliases that run code) - Git hooks: `.git/hooks/*` (can execute arbitrary code on git operations) - Tool configs: `.ripgreprc`, `.mcp.json` **Protected directories:** - IDE/editor settings: `.vscode`, `.idea` - Claude agent configs: `.claude/commands`, `.claude/agents` #### Process Isolation - Commands run in isolated namespaces (Linux) or with syscall restrictions (macOS) - PID namespace isolation prevents seeing/signaling host processes (Linux) - New session prevents terminal control attacks ### What Fence Blocks | Attack Vector | How It's Blocked | |---------------|------------------| | Arbitrary network access | Proxy filtering + OS-level enforcement | | Data exfiltration to unknown hosts | Only allowlisted domains reachable | | Writing malicious git hooks | `.git/hooks` always protected | | Modifying shell startup files | `.bashrc`, `.zshrc`, etc. always protected | | Reading sensitive paths | Configurable via `denyRead` | | Arbitrary filesystem writes | Only `allowWrite` paths permitted | ### What Fence Allows - Network access to explicitly allowed domains - Filesystem reads (except `denyRead` paths) - Filesystem writes to `allowWrite` paths - Process spawning within the sandbox - Inbound connections on exposed ports (`-p` flag) ### Limitations #### Domain-based filtering only Fence doesn't inspect request content. A sandboxed process can: - Download arbitrary code from allowed domains - Exfiltrate data by encoding it in requests to allowed domains - Use allowed domains as tunnels if they support it You're trusting your allowlist - if you allow `*.github.com`, the sandbox can reach any GitHub-hosted content. #### Root processes may escape restrictions - *Linux*: The sandboxed process has root inside its namespace and could potentially manipulate mounts, cgroups, or exploit kernel vulnerabilities. Network namespace isolation (`--unshare-net`) is solid, but filesystem isolation via bind mounts is more permeable. - *macOS*: `sandbox-exec` is robust at the kernel level, but root can modify system state before the sandbox starts or (on older macOS) load kernel extensions. #### macOS sandbox-exec is deprecated Apple deprecated `sandbox-exec` but it still works on current macOS (including Sequoia). There's no good alternative for CLI sandboxing: - App Sandbox requires signed `.app` bundles - Virtualization.framework is heavyweight - We use `sandbox-exec` pragmatically until Apple removes it #### Not for hostile code containment Fence is defense-in-depth for running semi-trusted code (npm install, build scripts, CI jobs), not a security boundary against actively malicious software designed to escape sandboxes.