Initial commit

This commit is contained in:
JY Tan
2025-12-18 13:14:07 -08:00
commit c02c91f051
16 changed files with 2579 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Binary (only at root, not cmd/fence or pkg/fence)
/fence
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Test artifacts
*.test
coverage.out

338
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,338 @@
# Fence 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<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
```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
│ ├── 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
}
```
- 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<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:
```mermaid
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:
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<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:
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 |
| Requirements | Built-in | bwrap, socat |
## 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 regardless of config to prevent common attack vectors:
- Shell configs: `.bashrc`, `.zshrc`, `.profile`, `.bash_profile`
- Git hooks: `.git/hooks/*` (can execute arbitrary code on git operations)
- Git config: `.gitconfig`, `.git/config` (can define aliases that run code)
- SSH config: `.ssh/config`, `.ssh/authorized_keys`
- Editor configs that can execute code: `.vimrc`, `.emacs`
#### 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.
## Dependencies
- `github.com/spf13/cobra` - CLI framework
- `github.com/things-go/go-socks5` - SOCKS5 proxy implementation
- `bubblewrap` (Linux) - Unprivileged sandboxing
- `socat` (Linux) - Socket relay for namespace bridging

194
README.md Normal file
View File

@@ -0,0 +1,194 @@
# fence
A Go implementation of process sandboxing with network and filesystem restrictions.
`fence` wraps arbitrary commands in a security sandbox, blocking network access by default and restricting filesystem operations based on configurable rules.
> [!NOTE]
> This is still a work in progress and may see significant changes.
## Features
- **Network Isolation**: All network access blocked by default
- **Domain Allowlisting**: Configure which domains are allowed
- **Filesystem Restrictions**: Control read/write access to paths
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
- **Library + CLI**: Use as a Go package or command-line tool
## Installation
```bash
go install github.com/Use-Tusk/fence/cmd/fence@latest
```
Or build from source:
```bash
git clone https://github.com/Use-Tusk/fence
cd fence
go build -o fence ./cmd/fence
```
## Quick Start
```bash
# This will be blocked (no domains allowed by default)
fence curl https://example.com
# Run with shell expansion
fence -c "echo hello && ls"
# Enable debug logging
fence -d curl https://example.com
```
## Configuration
Create `~/.fence.json` to configure allowed domains and filesystem access:
```json
{
"network": {
"allowedDomains": ["github.com", "*.npmjs.org", "registry.yarnpkg.com"],
"deniedDomains": ["evil.com"]
},
"filesystem": {
"denyRead": ["/etc/passwd"],
"allowWrite": [".", "/tmp"],
"denyWrite": [".git/hooks"]
}
}
```
### Network Configuration
| Field | Description |
|-------|-------------|
| `allowedDomains` | List of allowed domains. Supports wildcards like `*.example.com` |
| `deniedDomains` | List of denied domains (checked before allowed) |
| `allowUnixSockets` | List of allowed Unix socket paths (macOS) |
| `allowAllUnixSockets` | Allow all Unix sockets |
| `allowLocalBinding` | Allow binding to local ports |
### Filesystem Configuration
| Field | Description |
|-------|-------------|
| `denyRead` | Paths to deny reading (deny-only pattern) |
| `allowWrite` | Paths to allow writing |
| `denyWrite` | Paths to deny writing (takes precedence) |
| `allowGitConfig` | Allow writes to `.git/config` files |
## CLI Usage
```text
fence [flags] [command...]
Flags:
-c string Run command string directly (like sh -c)
-d, --debug Enable debug logging
-s, --settings Path to settings file (default: ~/.fence.json)
-h, --help Help for fence
```
### Examples
```bash
# Block all network (default behavior)
fence curl https://example.com
# Output: curl: (7) Couldn't connect to server
# Use a custom config
fence --settings ./my-config.json npm install
# Run a shell command
fence -c "git clone https://github.com/user/repo && cd repo && npm install"
# Debug mode shows proxy activity
fence -d wget https://example.com
```
## Library Usage
```go
package main
import (
"fmt"
"github.com/Use-Tusk/fence/pkg/fence"
)
func main() {
// Create config
cfg := &fence.Config{
Network: fence.NetworkConfig{
AllowedDomains: []string{"api.example.com"},
},
Filesystem: fence.FilesystemConfig{
AllowWrite: []string{"."},
},
}
// Create manager
manager := fence.NewManager(cfg, false)
defer manager.Cleanup()
// Initialize (starts proxies)
if err := manager.Initialize(); err != nil {
panic(err)
}
// Wrap a command
wrapped, err := manager.WrapCommand("curl https://api.example.com/data")
if err != nil {
panic(err)
}
fmt.Println("Sandboxed command:", wrapped)
}
```
## How It Works
### macOS (sandbox-exec)
On macOS, fence uses Apple's `sandbox-exec` with a generated seatbelt profile that:
- Denies all operations by default
- Allows specific Mach services needed for basic operation
- Controls network access via localhost proxies
- Restricts filesystem read/write based on configuration
### Linux (bubblewrap)
On Linux, fence uses `bubblewrap` (bwrap) with:
- Network namespace isolation (`--unshare-net`)
- Filesystem bind mounts for access control
- PID namespace isolation
- Unix socket bridges for proxy communication
For detailed security model, limitations, and architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Requirements
### macOS
- macOS 10.12+ (uses `sandbox-exec`)
- No additional dependencies
### Linux
- `bubblewrap` (bwrap)
- `socat` (for network bridging)
Install on Ubuntu/Debian:
```bash
apt install bubblewrap socat
```
## License
Apache-2.0

163
cmd/fence/main.go Normal file
View File

@@ -0,0 +1,163 @@
// Package main implements the fence CLI.
package main
import (
"fmt"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/sandbox"
"github.com/spf13/cobra"
)
var (
debug bool
settingsPath string
cmdString string
exposePorts []string
exitCode int
)
func main() {
rootCmd := &cobra.Command{
Use: "fence [flags] -- [command...]",
Short: "Run commands in a sandbox with network and filesystem restrictions",
Long: `fence is a command-line tool that runs commands in a sandboxed environment
with network and filesystem restrictions.
By default, all network access is blocked. Configure allowed domains in
~/.fence.json or pass a settings file with --settings.
Examples:
fence curl https://example.com # Will be blocked (no domains allowed)
fence -- curl -s https://example.com # Use -- to separate fence flags from command
fence -c "echo hello && ls" # Run with shell expansion
fence --settings config.json npm install
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
fence -p 3000 -p 8080 -c "npm start" # Expose multiple ports
Configuration file format (~/.fence.json):
{
"network": {
"allowedDomains": ["github.com", "*.npmjs.org"],
"deniedDomains": []
},
"filesystem": {
"denyRead": [],
"allowWrite": ["."],
"denyWrite": []
}
}`,
RunE: runCommand,
SilenceUsage: true,
SilenceErrors: true,
Args: cobra.ArbitraryArgs,
}
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: ~/.fence.json)")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
rootCmd.Flags().SetInterspersed(true)
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
exitCode = 1
}
os.Exit(exitCode)
}
func runCommand(cmd *cobra.Command, args []string) error {
var command string
if cmdString != "" {
command = cmdString
} else if len(args) > 0 {
command = strings.Join(args, " ")
} else {
return fmt.Errorf("no command specified. Use -c <command> or provide command arguments")
}
if debug {
fmt.Fprintf(os.Stderr, "[fence] Command: %s\n", command)
}
var ports []int
for _, p := range exposePorts {
port, err := strconv.Atoi(p)
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %s", p)
}
ports = append(ports, port)
}
if debug && len(ports) > 0 {
fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports)
}
configPath := settingsPath
if configPath == "" {
configPath = config.DefaultConfigPath()
}
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if cfg == nil {
if debug {
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
}
cfg = config.Default()
}
manager := sandbox.NewManager(cfg, debug)
manager.SetExposedPorts(ports)
defer manager.Cleanup()
if err := manager.Initialize(); err != nil {
return fmt.Errorf("failed to initialize sandbox: %w", err)
}
sandboxedCommand, err := manager.WrapCommand(command)
if err != nil {
return fmt.Errorf("failed to wrap command: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "[fence] Sandboxed command: %s\n", sandboxedCommand)
}
execCmd := exec.Command("sh", "-c", sandboxedCommand)
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
if execCmd.Process != nil {
execCmd.Process.Signal(sig)
}
// Give child time to exit, then cleanup will happen via defer
}()
if err := execCmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
// Set exit code but don't os.Exit() here - let deferred cleanup run
exitCode = exitErr.ExitCode()
return nil
}
return fmt.Errorf("command failed: %w", err)
}
return nil
}

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module github.com/Use-Tusk/fence
go 1.25
require (
github.com/spf13/cobra v1.8.1
github.com/things-go/go-socks5 v0.0.5
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

21
go.sum Normal file
View File

@@ -0,0 +1,21 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8=
github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

173
internal/config/config.go Normal file
View File

@@ -0,0 +1,173 @@
// Package config defines the configuration types and loading for fence.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
// Config is the main configuration for fence.
type Config struct {
Network NetworkConfig `json:"network"`
Filesystem FilesystemConfig `json:"filesystem"`
AllowPty bool `json:"allowPty,omitempty"`
}
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
AllowedDomains []string `json:"allowedDomains"`
DeniedDomains []string `json:"deniedDomains"`
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
}
// FilesystemConfig defines filesystem restrictions.
type FilesystemConfig struct {
DenyRead []string `json:"denyRead"`
AllowWrite []string `json:"allowWrite"`
DenyWrite []string `json:"denyWrite"`
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
}
// Default returns the default configuration with all network blocked.
func Default() *Config {
return &Config{
Network: NetworkConfig{
AllowedDomains: []string{},
DeniedDomains: []string{},
},
Filesystem: FilesystemConfig{
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
},
}
}
// DefaultConfigPath returns the default config file path.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ".fence.json"
}
return filepath.Join(home, ".fence.json")
}
// Load loads configuration from a file path.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Handle empty file
if len(strings.TrimSpace(string(data))) == 0 {
return nil, nil
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("invalid JSON in config file: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &cfg, nil
}
// Validate validates the configuration.
func (c *Config) Validate() error {
for _, domain := range c.Network.AllowedDomains {
if err := validateDomainPattern(domain); err != nil {
return fmt.Errorf("invalid allowed domain %q: %w", domain, err)
}
}
for _, domain := range c.Network.DeniedDomains {
if err := validateDomainPattern(domain); err != nil {
return fmt.Errorf("invalid denied domain %q: %w", domain, err)
}
}
if slices.Contains(c.Filesystem.DenyRead, "") {
return errors.New("filesystem.denyRead contains empty path")
}
if slices.Contains(c.Filesystem.AllowWrite, "") {
return errors.New("filesystem.allowWrite contains empty path")
}
if slices.Contains(c.Filesystem.DenyWrite, "") {
return errors.New("filesystem.denyWrite contains empty path")
}
return nil
}
func validateDomainPattern(pattern string) error {
if pattern == "localhost" {
return nil
}
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") || strings.Contains(pattern, ":") {
return errors.New("domain pattern cannot contain protocol, path, or port")
}
// Handle wildcard patterns
if strings.HasPrefix(pattern, "*.") {
domain := pattern[2:]
// Must have at least one more dot after the wildcard
if !strings.Contains(domain, ".") {
return errors.New("wildcard pattern too broad (e.g., *.com not allowed)")
}
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return errors.New("invalid domain format")
}
// Check each part has content
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return errors.New("wildcard pattern too broad")
}
if slices.Contains(parts, "") {
return errors.New("invalid domain format")
}
return nil
}
// Reject other uses of wildcards
if strings.Contains(pattern, "*") {
return errors.New("only *.domain.com wildcard patterns are allowed")
}
// Regular domains must have at least one dot
if !strings.Contains(pattern, ".") || strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
return errors.New("invalid domain format")
}
return nil
}
// MatchesDomain checks if a hostname matches a domain pattern.
func MatchesDomain(hostname, pattern string) bool {
hostname = strings.ToLower(hostname)
pattern = strings.ToLower(pattern)
// Wildcard pattern like *.example.com
if strings.HasPrefix(pattern, "*.") {
baseDomain := pattern[2:]
return strings.HasSuffix(hostname, "."+baseDomain)
}
// Exact match
return hostname == pattern
}

View File

@@ -0,0 +1,34 @@
// Package platform provides OS detection utilities.
package platform
import "runtime"
// Type represents the detected platform.
type Type string
const (
MacOS Type = "macos"
Linux Type = "linux"
Windows Type = "windows"
Unknown Type = "unknown"
)
// Detect returns the current platform type.
func Detect() Type {
switch runtime.GOOS {
case "darwin":
return MacOS
case "linux":
return Linux
case "windows":
return Windows
default:
return Unknown
}
}
// IsSupported returns true if the platform supports sandboxing.
func IsSupported() bool {
p := Detect()
return p == MacOS || p == Linux
}

278
internal/proxy/http.go Normal file
View File

@@ -0,0 +1,278 @@
// Package proxy provides HTTP and SOCKS5 proxy servers with domain filtering.
package proxy
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/Use-Tusk/fence/internal/config"
)
// FilterFunc determines if a connection to host:port should be allowed.
type FilterFunc func(host string, port int) bool
// HTTPProxy is an HTTP/HTTPS proxy server with domain filtering.
type HTTPProxy struct {
server *http.Server
listener net.Listener
filter FilterFunc
debug bool
mu sync.RWMutex
running bool
}
// NewHTTPProxy creates a new HTTP proxy with the given filter.
func NewHTTPProxy(filter FilterFunc, debug bool) *HTTPProxy {
return &HTTPProxy{
filter: filter,
debug: debug,
}
}
// Start starts the HTTP proxy on a random available port.
func (p *HTTPProxy) Start() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("failed to listen: %w", err)
}
p.listener = listener
p.server = &http.Server{
Handler: http.HandlerFunc(p.handleRequest),
}
p.mu.Lock()
p.running = true
p.mu.Unlock()
go func() {
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
p.logDebug("HTTP proxy server error: %v", err)
}
}()
addr := listener.Addr().(*net.TCPAddr)
p.logDebug("HTTP proxy listening on localhost:%d", addr.Port)
return addr.Port, nil
}
// Stop stops the HTTP proxy.
func (p *HTTPProxy) Stop() error {
p.mu.Lock()
p.running = false
p.mu.Unlock()
if p.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return p.server.Shutdown(ctx)
}
return nil
}
// Port returns the port the proxy is listening on.
func (p *HTTPProxy) Port() int {
if p.listener == nil {
return 0
}
return p.listener.Addr().(*net.TCPAddr).Port
}
func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
p.handleConnect(w, r)
} else {
p.handleHTTP(w, r)
}
}
// handleConnect handles HTTPS CONNECT requests (tunnel).
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
host, portStr, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
portStr = "443"
}
port := 443
if portStr != "" {
fmt.Sscanf(portStr, "%d", &port)
}
// Check if allowed
if !p.filter(host, port) {
p.logDebug("CONNECT blocked: %s:%d", host, port)
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
return
}
// Connect to target
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
if err != nil {
p.logDebug("CONNECT dial failed: %s:%d: %v", host, port, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer targetConn.Close()
// Hijack the connection
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, "Failed to hijack connection", http.StatusInternalServerError)
return
}
defer clientConn.Close()
clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
// Pipe data bidirectionally
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(targetConn, clientConn)
}()
go func() {
defer wg.Done()
io.Copy(clientConn, targetConn)
}()
wg.Wait()
}
// handleHTTP handles regular HTTP proxy requests.
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
targetURL, err := url.Parse(r.RequestURI)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
host := targetURL.Hostname()
port := 80
if targetURL.Port() != "" {
fmt.Sscanf(targetURL.Port(), "%d", &port)
} else if targetURL.Scheme == "https" {
port = 443
}
if !p.filter(host, port) {
p.logDebug("HTTP blocked: %s:%d", host, port)
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
return
}
// Create new request and copy headers
proxyReq, err := http.NewRequest(r.Method, r.RequestURI, r.Body)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
for key, values := range r.Header {
for _, value := range values {
proxyReq.Header.Add(key, value)
}
}
proxyReq.Host = targetURL.Host
// Remove hop-by-hop headers
proxyReq.Header.Del("Proxy-Connection")
proxyReq.Header.Del("Proxy-Authorization")
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(proxyReq)
if err != nil {
p.logDebug("HTTP request failed: %v", err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Copy response headers
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
if p.debug {
fmt.Printf("[fence:http] "+format+"\n", args...)
}
}
// CreateDomainFilter creates a filter function from a config.
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
return func(host string, port int) bool {
if cfg == nil {
// No config = deny all
if debug {
fmt.Printf("[fence:filter] No config, denying: %s:%d\n", host, port)
}
return false
}
// Check denied domains first
for _, denied := range cfg.Network.DeniedDomains {
if config.MatchesDomain(host, denied) {
if debug {
fmt.Printf("[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
}
return false
}
}
// Check allowed domains
for _, allowed := range cfg.Network.AllowedDomains {
if config.MatchesDomain(host, allowed) {
if debug {
fmt.Printf("[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
}
return true
}
}
if debug {
fmt.Printf("[fence:filter] No matching rule, denying: %s:%d\n", host, port)
}
return false
}
}
// GetHostFromRequest extracts the hostname from a request.
func GetHostFromRequest(r *http.Request) string {
host := r.Host
if h := r.URL.Hostname(); h != "" {
host = h
}
// Strip port
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
return host
}

95
internal/proxy/socks.go Normal file
View File

@@ -0,0 +1,95 @@
package proxy
import (
"context"
"fmt"
"net"
"github.com/things-go/go-socks5"
)
// SOCKSProxy is a SOCKS5 proxy server with domain filtering.
type SOCKSProxy struct {
server *socks5.Server
listener net.Listener
filter FilterFunc
debug bool
port int
}
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
func NewSOCKSProxy(filter FilterFunc, debug bool) *SOCKSProxy {
return &SOCKSProxy{
filter: filter,
debug: debug,
}
}
// fenceRuleSet implements socks5.RuleSet for domain filtering.
type fenceRuleSet struct {
filter FilterFunc
debug bool
}
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
host := req.DestAddr.FQDN
if host == "" {
host = req.DestAddr.IP.String()
}
port := req.DestAddr.Port
allowed := r.filter(host, port)
if r.debug {
if allowed {
fmt.Printf("[fence:socks] Allowed: %s:%d\n", host, port)
} else {
fmt.Printf("[fence:socks] Blocked: %s:%d\n", host, port)
}
}
return ctx, allowed
}
// Start starts the SOCKS5 proxy on a random available port.
func (p *SOCKSProxy) Start() (int, error) {
// Create listener first to get a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("failed to listen: %w", err)
}
p.listener = listener
p.port = listener.Addr().(*net.TCPAddr).Port
server := socks5.NewServer(
socks5.WithRule(&fenceRuleSet{
filter: p.filter,
debug: p.debug,
}),
)
p.server = server
go func() {
if err := p.server.Serve(p.listener); err != nil {
if p.debug {
fmt.Printf("[fence:socks] Server error: %v\n", err)
}
}
}()
if p.debug {
fmt.Printf("[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
}
return p.port, nil
}
// Stop stops the SOCKS5 proxy.
func (p *SOCKSProxy) Stop() error {
if p.listener != nil {
return p.listener.Close()
}
return nil
}
// Port returns the port the proxy is listening on.
func (p *SOCKSProxy) Port() int {
return p.port
}

View File

@@ -0,0 +1,84 @@
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
"os"
"path/filepath"
)
// DangerousFiles lists files that should be protected from writes.
// These files can be used for code execution or data exfiltration.
var DangerousFiles = []string{
".gitconfig",
".gitmodules",
".bashrc",
".bash_profile",
".zshrc",
".zprofile",
".profile",
".ripgreprc",
".mcp.json",
}
// DangerousDirectories lists directories that should be protected from writes.
// Excludes .git since we need it writable for git operations.
var DangerousDirectories = []string{
".vscode",
".idea",
".claude/commands",
".claude/agents",
}
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
func GetDefaultWritePaths() []string {
home, _ := os.UserHomeDir()
paths := []string{
"/dev/stdout",
"/dev/stderr",
"/dev/null",
"/dev/tty",
"/dev/dtracehelper",
"/dev/autofs_nowait",
"/tmp/fence",
"/private/tmp/fence",
}
if home != "" {
paths = append(paths,
filepath.Join(home, ".npm/_logs"),
filepath.Join(home, ".fence/debug"),
)
}
return paths
}
// GetMandatoryDenyPatterns returns glob patterns for paths that must always be protected.
func GetMandatoryDenyPatterns(cwd string, allowGitConfig bool) []string {
var patterns []string
// Dangerous files - in CWD and all subdirectories
for _, f := range DangerousFiles {
patterns = append(patterns, filepath.Join(cwd, f))
patterns = append(patterns, "**/"+f)
}
// Dangerous directories
for _, d := range DangerousDirectories {
patterns = append(patterns, filepath.Join(cwd, d))
patterns = append(patterns, "**/"+d+"/**")
}
// Git hooks are always blocked
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
patterns = append(patterns, "**/.git/hooks/**")
// Git config is conditionally blocked
if !allowGitConfig {
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
patterns = append(patterns, "**/.git/config")
}
return patterns
}

303
internal/sandbox/linux.go Normal file
View File

@@ -0,0 +1,303 @@
package sandbox
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Use-Tusk/fence/internal/config"
)
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound).
type LinuxBridge struct {
HTTPSocketPath string
SOCKSSocketPath string
httpProcess *exec.Cmd
socksProcess *exec.Cmd
debug bool
}
// ReverseBridge holds the socat bridge processes for inbound connections.
type ReverseBridge struct {
Ports []int
SocketPaths []string // Unix socket paths for each port
processes []*exec.Cmd
debug bool
}
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
// This allows sandboxed processes to communicate with the host's proxy (outbound).
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
}
id := make([]byte, 8)
rand.Read(id)
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID))
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
bridge := &LinuxBridge{
HTTPSocketPath: httpSocketPath,
SOCKSSocketPath: socksSocketPath,
debug: debug,
}
// Start HTTP bridge: Unix socket -> TCP proxy
httpArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath),
fmt.Sprintf("TCP:localhost:%d", httpProxyPort),
}
bridge.httpProcess = exec.Command("socat", httpArgs...)
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting HTTP bridge: socat %s\n", strings.Join(httpArgs, " "))
}
if err := bridge.httpProcess.Start(); err != nil {
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err)
}
// Start SOCKS bridge: Unix socket -> TCP proxy
socksArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socksSocketPath),
fmt.Sprintf("TCP:localhost:%d", socksProxyPort),
}
bridge.socksProcess = exec.Command("socat", socksArgs...)
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting SOCKS bridge: socat %s\n", strings.Join(socksArgs, " "))
}
if err := bridge.socksProcess.Start(); err != nil {
bridge.Cleanup()
return nil, fmt.Errorf("failed to start SOCKS bridge: %w", err)
}
// Wait for sockets to be created
for i := 0; i < 50; i++ { // 5 seconds max
httpExists := fileExists(httpSocketPath)
socksExists := fileExists(socksSocketPath)
if httpExists && socksExists {
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges ready (HTTP: %s, SOCKS: %s)\n", httpSocketPath, socksSocketPath)
}
return bridge, nil
}
time.Sleep(100 * time.Millisecond)
}
bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for bridge sockets to be created")
}
// Cleanup stops the bridge processes and removes socket files.
func (b *LinuxBridge) Cleanup() {
if b.httpProcess != nil && b.httpProcess.Process != nil {
b.httpProcess.Process.Kill()
b.httpProcess.Wait()
}
if b.socksProcess != nil && b.socksProcess.Process != nil {
b.socksProcess.Process.Kill()
b.socksProcess.Wait()
}
// Clean up socket files
os.Remove(b.HTTPSocketPath)
os.Remove(b.SOCKSSocketPath)
if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges cleaned up\n")
}
}
// NewReverseBridge creates Unix socket bridges for inbound connections.
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
if len(ports) == 0 {
return nil, nil
}
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
}
id := make([]byte, 8)
rand.Read(id)
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
bridge := &ReverseBridge{
Ports: ports,
debug: debug,
}
for _, port := range ports {
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-rev-%d-%s.sock", port, socketID))
bridge.SocketPaths = append(bridge.SocketPaths, socketPath)
// Start reverse bridge: TCP listen on host port -> Unix socket
// The sandbox will create the Unix socket with UNIX-LISTEN
// We use retry to wait for the socket to be created by the sandbox
args := []string{
fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr", port),
fmt.Sprintf("UNIX-CONNECT:%s,retry=50,interval=0.1", socketPath),
}
proc := exec.Command("socat", args...)
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting reverse bridge for port %d: socat %s\n", port, strings.Join(args, " "))
}
if err := proc.Start(); err != nil {
bridge.Cleanup()
return nil, fmt.Errorf("failed to start reverse bridge for port %d: %w", port, err)
}
bridge.processes = append(bridge.processes, proc)
}
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges ready for ports: %v\n", ports)
}
return bridge, nil
}
// Cleanup stops the reverse bridge processes and removes socket files.
func (b *ReverseBridge) Cleanup() {
for _, proc := range b.processes {
if proc != nil && proc.Process != nil {
proc.Process.Kill()
proc.Wait()
}
}
// Clean up socket files
for _, socketPath := range b.SocketPaths {
os.Remove(socketPath)
}
if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges cleaned up\n")
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
// Check for bwrap
if _, err := exec.LookPath("bwrap"); err != nil {
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
}
// Find shell
shell := "bash"
shellPath, err := exec.LookPath(shell)
if err != nil {
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
// Build bwrap args
bwrapArgs := []string{
"bwrap",
"--new-session",
"--die-with-parent",
"--unshare-net", // Network namespace isolation
"--unshare-pid", // PID namespace isolation
"--bind", "/", "/", // Bind root filesystem
"--dev", "/dev", // Mount /dev
"--proc", "/proc", // Mount /proc
}
// Bind the outbound Unix sockets into the sandbox
if bridge != nil {
bwrapArgs = append(bwrapArgs,
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath,
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
)
}
// Note: Reverse (inbound) Unix sockets don't need explicit binding
// because we use --bind / / which shares the entire filesystem.
// The sandbox-side socat creates the socket, which is visible to the host.
// Add environment variables for the sandbox
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up socat listeners and runs the user command
var innerScript strings.Builder
if bridge != nil {
// Set up outbound socat listeners inside the sandbox
innerScript.WriteString(fmt.Sprintf(`
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy)
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
HTTP_PID=$!
# Start SOCKS proxy listener (port 1080 -> Unix socket -> host SOCKS proxy)
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
SOCKS_PID=$!
# Set proxy environment variables
export HTTP_PROXY=http://127.0.0.1:3128
export HTTPS_PROXY=http://127.0.0.1:3128
export http_proxy=http://127.0.0.1:3128
export https_proxy=http://127.0.0.1:3128
export ALL_PROXY=socks5h://127.0.0.1:1080
export all_proxy=socks5h://127.0.0.1:1080
export NO_PROXY=localhost,127.0.0.1
export no_proxy=localhost,127.0.0.1
export FENCE_SANDBOX=1
`, bridge.HTTPSocketPath, bridge.SOCKSSocketPath))
}
// Set up reverse (inbound) socat listeners inside the sandbox
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
innerScript.WriteString("\n# Start reverse bridge listeners for inbound connections\n")
for i, port := range reverseBridge.Ports {
socketPath := reverseBridge.SocketPaths[i]
// Listen on Unix socket, forward to localhost:port inside the sandbox
innerScript.WriteString(fmt.Sprintf(
"socat UNIX-LISTEN:%s,fork,reuseaddr TCP:127.0.0.1:%d >/dev/null 2>&1 &\n",
socketPath, port,
))
innerScript.WriteString(fmt.Sprintf("REV_%d_PID=$!\n", port))
}
innerScript.WriteString("\n")
}
// Add cleanup function
innerScript.WriteString(`
# Cleanup function
cleanup() {
jobs -p | xargs -r kill 2>/dev/null
}
trap cleanup EXIT
# Small delay to ensure socat listeners are ready
sleep 0.1
# Run the user command
`)
innerScript.WriteString(command)
innerScript.WriteString("\n")
bwrapArgs = append(bwrapArgs, innerScript.String())
if debug {
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
fmt.Fprintf(os.Stderr, "[fence:linux] Wrapping command with bwrap (network filtering + inbound ports: %v)\n", reverseBridge.Ports)
} else {
fmt.Fprintf(os.Stderr, "[fence:linux] Wrapping command with bwrap (network filtering via socat bridges)\n")
}
}
return ShellQuote(bwrapArgs), nil
}

558
internal/sandbox/macos.go Normal file
View File

@@ -0,0 +1,558 @@
package sandbox
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/Use-Tusk/fence/internal/config"
)
// sessionSuffix is a unique identifier for this process session.
var sessionSuffix = generateSessionSuffix()
func generateSessionSuffix() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return "_" + hex.EncodeToString(bytes)[:9] + "_SBX"
}
// MacOSSandboxParams contains parameters for macOS sandbox wrapping.
type MacOSSandboxParams struct {
Command string
NeedsNetworkRestriction bool
HTTPProxyPort int
SOCKSProxyPort int
AllowUnixSockets []string
AllowAllUnixSockets bool
AllowLocalBinding bool
ReadDenyPaths []string
WriteAllowPaths []string
WriteDenyPaths []string
AllowPty bool
AllowGitConfig bool
Shell string
}
// GlobToRegex converts a glob pattern to a regex for macOS sandbox profiles.
func GlobToRegex(glob string) string {
result := "^"
// Escape regex special characters (except glob chars)
escaped := regexp.QuoteMeta(glob)
// Restore glob patterns and convert them
// Order matters: ** before *
escaped = strings.ReplaceAll(escaped, `\*\*/`, "(.*/)?")
escaped = strings.ReplaceAll(escaped, `\*\*`, ".*")
escaped = strings.ReplaceAll(escaped, `\*`, "[^/]*")
escaped = strings.ReplaceAll(escaped, `\?`, "[^/]")
result += escaped + "$"
return result
}
// escapePath escapes a path for sandbox profile using JSON encoding.
func escapePath(path string) string {
// Use Go's string quoting which handles escaping
return fmt.Sprintf("%q", path)
}
// getAncestorDirectories returns all ancestor directories of a path.
func getAncestorDirectories(pathStr string) []string {
var ancestors []string
current := filepath.Dir(pathStr)
for current != "/" && current != "." {
ancestors = append(ancestors, current)
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return ancestors
}
// getTmpdirParent gets the TMPDIR parent if it matches macOS pattern.
func getTmpdirParent() []string {
tmpdir := os.Getenv("TMPDIR")
if tmpdir == "" {
return nil
}
// Match /var/folders/XX/YYY/T/
pattern := regexp.MustCompile(`^/(private/)?var/folders/[^/]{2}/[^/]+/T/?$`)
if !pattern.MatchString(tmpdir) {
return nil
}
parent := strings.TrimSuffix(tmpdir, "/")
parent = strings.TrimSuffix(parent, "/T")
// Return both /var/ and /private/var/ versions
if strings.HasPrefix(parent, "/private/var/") {
return []string{parent, strings.Replace(parent, "/private", "", 1)}
} else if strings.HasPrefix(parent, "/var/") {
return []string{parent, "/private" + parent}
}
return []string{parent}
}
// generateReadRules generates filesystem read rules for the sandbox profile.
func generateReadRules(denyPaths []string, logTag string) []string {
var rules []string
// Allow all reads by default
rules = append(rules, "(allow file-read*)")
// Deny specific paths
for _, pathPattern := range denyPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
// Block file movement to prevent bypass
rules = append(rules, generateMoveBlockingRules(denyPaths, logTag)...)
return rules
}
// generateWriteRules generates filesystem write rules for the sandbox profile.
func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
var rules []string
// Allow TMPDIR parent on macOS
for _, tmpdirParent := range getTmpdirParent() {
normalized := NormalizePath(tmpdirParent)
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Generate allow rules
for _, pathPattern := range allowPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
// Combine user-specified and mandatory deny patterns
cwd, _ := os.Getwd()
allDenyPaths := append(denyPaths, GetMandatoryDenyPatterns(cwd, allowGitConfig)...)
for _, pathPattern := range allDenyPaths {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-write*",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
} else {
rules = append(rules,
"(deny file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
// Block file movement
rules = append(rules, generateMoveBlockingRules(allDenyPaths, logTag)...)
return rules
}
// generateMoveBlockingRules generates rules to prevent file movement bypasses.
func generateMoveBlockingRules(pathPatterns []string, logTag string) []string {
var rules []string
for _, pathPattern := range pathPatterns {
normalized := NormalizePath(pathPattern)
if ContainsGlobChars(normalized) {
regex := GlobToRegex(normalized)
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (regex %s)", escapePath(regex)),
fmt.Sprintf(" (with message %q))", logTag),
)
// For globs, extract static prefix and block ancestor moves
staticPrefix := strings.Split(normalized, "*")[0]
if staticPrefix != "" && staticPrefix != "/" {
baseDir := staticPrefix
if strings.HasSuffix(baseDir, "/") {
baseDir = baseDir[:len(baseDir)-1]
} else {
baseDir = filepath.Dir(staticPrefix)
}
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (literal %s)", escapePath(baseDir)),
fmt.Sprintf(" (with message %q))", logTag),
)
for _, ancestor := range getAncestorDirectories(baseDir) {
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (literal %s)", escapePath(ancestor)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
} else {
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (subpath %s)", escapePath(normalized)),
fmt.Sprintf(" (with message %q))", logTag),
)
for _, ancestor := range getAncestorDirectories(normalized) {
rules = append(rules,
"(deny file-write-unlink",
fmt.Sprintf(" (literal %s)", escapePath(ancestor)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
}
}
return rules
}
// GenerateSandboxProfile generates a complete macOS sandbox profile.
func GenerateSandboxProfile(params MacOSSandboxParams) string {
logTag := "CMD64_" + EncodeSandboxedCommand(params.Command) + "_END" + sessionSuffix
var profile strings.Builder
// Header
profile.WriteString("(version 1)\n")
profile.WriteString(fmt.Sprintf("(deny default (with message %q))\n\n", logTag))
profile.WriteString(fmt.Sprintf("; LogTag: %s\n\n", logTag))
// Essential permissions - based on Chrome sandbox policy
profile.WriteString(`; Essential permissions - based on Chrome sandbox policy
; Process permissions
(allow process-exec)
(allow process-fork)
(allow process-info* (target same-sandbox))
(allow signal (target same-sandbox))
(allow mach-priv-task-port (target same-sandbox))
; User preferences
(allow user-preference-read)
; Mach IPC - specific services only
(allow mach-lookup
(global-name "com.apple.audio.systemsoundserver")
(global-name "com.apple.distributed_notifications@Uv3")
(global-name "com.apple.FontObjectsServer")
(global-name "com.apple.fonts")
(global-name "com.apple.logd")
(global-name "com.apple.lsd.mapdb")
(global-name "com.apple.PowerManagement.control")
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc")
(global-name "com.apple.coreservices.launchservicesd")
)
; POSIX IPC
(allow ipc-posix-shm)
(allow ipc-posix-sem)
; IOKit
(allow iokit-open
(iokit-registry-entry-class "IOSurfaceRootUserClient")
(iokit-registry-entry-class "RootDomainUserClient")
(iokit-user-client-class "IOSurfaceSendRight")
)
(allow iokit-get-properties)
; System socket for network info
(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))
; sysctl reads
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "kern.argmax")
(sysctl-name "kern.bootargs")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfiles")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.ngroups")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.tcsm_available")
(sysctl-name "kern.tcsm_enable")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "kern.willshutdown")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "machdep.ptrauth_enabled")
(sysctl-name "security.mac.lockdown_mode_state")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.optional.arm")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.all")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "machdep.cpu.")
(sysctl-name-prefix "net.routetable.")
)
; V8 thread calculations
(allow sysctl-write
(sysctl-name "kern.tcsm_enable")
)
; Distributed notifications
(allow distributed-notification-post)
; Security server
(allow mach-lookup (global-name "com.apple.SecurityServer"))
; Device I/O
(allow file-ioctl (literal "/dev/null"))
(allow file-ioctl (literal "/dev/zero"))
(allow file-ioctl (literal "/dev/random"))
(allow file-ioctl (literal "/dev/urandom"))
(allow file-ioctl (literal "/dev/dtracehelper"))
(allow file-ioctl (literal "/dev/tty"))
(allow file-ioctl file-read-data file-write-data
(require-all
(literal "/dev/null")
(vnode-type CHARACTER-DEVICE)
)
)
`)
// Network rules
profile.WriteString("; Network\n")
if !params.NeedsNetworkRestriction {
profile.WriteString("(allow network*)\n")
} else {
if params.AllowLocalBinding {
profile.WriteString(`(allow network-bind (local ip "localhost:*"))
(allow network-inbound (local ip "localhost:*"))
(allow network-outbound (local ip "localhost:*"))
`)
}
if params.AllowAllUnixSockets {
profile.WriteString("(allow network* (subpath \"/\"))\n")
} else if len(params.AllowUnixSockets) > 0 {
for _, socketPath := range params.AllowUnixSockets {
normalized := NormalizePath(socketPath)
profile.WriteString(fmt.Sprintf("(allow network* (subpath %s))\n", escapePath(normalized)))
}
}
if params.HTTPProxyPort > 0 {
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
(allow network-inbound (local ip "localhost:%d"))
(allow network-outbound (remote ip "localhost:%d"))
`, params.HTTPProxyPort, params.HTTPProxyPort, params.HTTPProxyPort))
}
if params.SOCKSProxyPort > 0 {
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
(allow network-inbound (local ip "localhost:%d"))
(allow network-outbound (remote ip "localhost:%d"))
`, params.SOCKSProxyPort, params.SOCKSProxyPort, params.SOCKSProxyPort))
}
}
profile.WriteString("\n")
// Read rules
profile.WriteString("; File read\n")
for _, rule := range generateReadRules(params.ReadDenyPaths, logTag) {
profile.WriteString(rule + "\n")
}
profile.WriteString("\n")
// Write rules
profile.WriteString("; File write\n")
for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
profile.WriteString(rule + "\n")
}
// PTY support
if params.AllowPty {
profile.WriteString(`
; Pseudo-terminal (pty) support
(allow pseudo-tty)
(allow file-ioctl
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
(allow file-read* file-write*
(literal "/dev/ptmx")
(regex #"^/dev/ttys")
)
`)
}
return profile.String()
}
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
// Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
// Enable local binding if ports are exposed or if explicitly configured
allowLocalBinding := cfg.Network.AllowLocalBinding || len(exposedPorts) > 0
params := MacOSSandboxParams{
Command: command,
NeedsNetworkRestriction: needsNetwork || len(cfg.Network.AllowedDomains) == 0, // Block if no domains allowed
HTTPProxyPort: httpPort,
SOCKSProxyPort: socksPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,
WriteDenyPaths: cfg.Filesystem.DenyWrite,
AllowPty: cfg.AllowPty,
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
}
if debug && len(exposedPorts) > 0 {
fmt.Fprintf(os.Stderr, "[fence:macos] Enabling local binding for exposed ports: %v\n", exposedPorts)
}
profile := GenerateSandboxProfile(params)
// Find shell
shell := params.Shell
if shell == "" {
shell = "bash"
}
shellPath, err := exec.LookPath(shell)
if err != nil {
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
// Generate proxy environment variables
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
// Build the command
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
var parts []string
parts = append(parts, "env")
parts = append(parts, proxyEnvs...)
parts = append(parts, "sandbox-exec", "-p", profile, shellPath, "-c", command)
return ShellQuote(parts), nil
}
// ShellQuote quotes a slice of strings for shell execution.
func ShellQuote(args []string) string {
var quoted []string
for _, arg := range args {
if needsQuoting(arg) {
quoted = append(quoted, fmt.Sprintf("'%s'", strings.ReplaceAll(arg, "'", "'\\''")))
} else {
quoted = append(quoted, arg)
}
}
return strings.Join(quoted, " ")
}
func needsQuoting(s string) bool {
for _, c := range s {
if c == ' ' || c == '\t' || c == '\n' || c == '"' || c == '\'' ||
c == '\\' || c == '$' || c == '`' || c == '!' || c == '*' ||
c == '?' || c == '[' || c == ']' || c == '(' || c == ')' ||
c == '{' || c == '}' || c == '<' || c == '>' || c == '|' ||
c == '&' || c == ';' || c == '#' {
return true
}
}
return len(s) == 0
}

144
internal/sandbox/manager.go Normal file
View File

@@ -0,0 +1,144 @@
package sandbox
import (
"fmt"
"os"
"github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/platform"
"github.com/Use-Tusk/fence/internal/proxy"
)
// Manager handles sandbox initialization and command wrapping.
type Manager struct {
config *config.Config
httpProxy *proxy.HTTPProxy
socksProxy *proxy.SOCKSProxy
linuxBridge *LinuxBridge
reverseBridge *ReverseBridge
httpPort int
socksPort int
exposedPorts []int
debug bool
initialized bool
}
// NewManager creates a new sandbox manager.
func NewManager(cfg *config.Config, debug bool) *Manager {
return &Manager{
config: cfg,
debug: debug,
}
}
// SetExposedPorts sets the ports to expose for inbound connections.
func (m *Manager) SetExposedPorts(ports []int) {
m.exposedPorts = ports
}
// Initialize sets up the sandbox infrastructure (proxies, etc.).
func (m *Manager) Initialize() error {
if m.initialized {
return nil
}
if !platform.IsSupported() {
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
}
filter := proxy.CreateDomainFilter(m.config, m.debug)
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug)
httpPort, err := m.httpProxy.Start()
if err != nil {
return fmt.Errorf("failed to start HTTP proxy: %w", err)
}
m.httpPort = httpPort
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug)
socksPort, err := m.socksProxy.Start()
if err != nil {
m.httpProxy.Stop()
return fmt.Errorf("failed to start SOCKS proxy: %w", err)
}
m.socksPort = socksPort
// On Linux, set up the socat bridges
if platform.Detect() == platform.Linux {
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug)
if err != nil {
m.httpProxy.Stop()
m.socksProxy.Stop()
return fmt.Errorf("failed to initialize Linux bridge: %w", err)
}
m.linuxBridge = bridge
// Set up reverse bridge for exposed ports (inbound connections)
if len(m.exposedPorts) > 0 {
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
if err != nil {
m.linuxBridge.Cleanup()
m.httpProxy.Stop()
m.socksProxy.Stop()
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
}
m.reverseBridge = reverseBridge
}
}
m.initialized = true
m.logDebug("Sandbox manager initialized (HTTP proxy: %d, SOCKS proxy: %d)", m.httpPort, m.socksPort)
return nil
}
// WrapCommand wraps a command with sandbox restrictions.
func (m *Manager) WrapCommand(command string) (string, error) {
if !m.initialized {
if err := m.Initialize(); err != nil {
return "", err
}
}
plat := platform.Detect()
switch plat {
case platform.MacOS:
return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug)
case platform.Linux:
return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug)
default:
return "", fmt.Errorf("unsupported platform: %s", plat)
}
}
// Cleanup stops the proxies and cleans up resources.
func (m *Manager) Cleanup() {
if m.reverseBridge != nil {
m.reverseBridge.Cleanup()
}
if m.linuxBridge != nil {
m.linuxBridge.Cleanup()
}
if m.httpProxy != nil {
m.httpProxy.Stop()
}
if m.socksProxy != nil {
m.socksProxy.Stop()
}
m.logDebug("Sandbox manager cleaned up")
}
func (m *Manager) logDebug(format string, args ...interface{}) {
if m.debug {
fmt.Fprintf(os.Stderr, "[fence] "+format+"\n", args...)
}
}
// HTTPPort returns the HTTP proxy port.
func (m *Manager) HTTPPort() int {
return m.httpPort
}
// SOCKSPort returns the SOCKS proxy port.
func (m *Manager) SOCKSPort() int {
return m.socksPort
}

125
internal/sandbox/utils.go Normal file
View File

@@ -0,0 +1,125 @@
package sandbox
import (
"encoding/base64"
"os"
"path/filepath"
"strconv"
"strings"
)
// ContainsGlobChars checks if a path pattern contains glob characters.
func ContainsGlobChars(pattern string) bool {
return strings.ContainsAny(pattern, "*?[]")
}
// RemoveTrailingGlobSuffix removes trailing /** from a path pattern.
func RemoveTrailingGlobSuffix(pattern string) string {
return strings.TrimSuffix(pattern, "/**")
}
// NormalizePath normalizes a path for sandbox configuration.
// Handles tilde expansion and relative paths.
func NormalizePath(pathPattern string) string {
home, _ := os.UserHomeDir()
cwd, _ := os.Getwd()
normalized := pathPattern
// Expand ~ to home directory
if pathPattern == "~" {
normalized = home
} else if strings.HasPrefix(pathPattern, "~/") {
normalized = filepath.Join(home, pathPattern[2:])
} else if strings.HasPrefix(pathPattern, "./") || strings.HasPrefix(pathPattern, "../") {
normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern))
} else if !filepath.IsAbs(pathPattern) && !ContainsGlobChars(pathPattern) {
normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern))
}
// For non-glob patterns, try to resolve symlinks
if !ContainsGlobChars(normalized) {
if resolved, err := filepath.EvalSymlinks(normalized); err == nil {
return resolved
}
}
return normalized
}
// GenerateProxyEnvVars creates environment variables for proxy configuration.
func GenerateProxyEnvVars(httpPort, socksPort int) []string {
envVars := []string{
"FENCE_SANDBOX=1",
"TMPDIR=/tmp/fence",
}
if httpPort == 0 && socksPort == 0 {
return envVars
}
// NO_PROXY for localhost and private networks
noProxy := strings.Join([]string{
"localhost",
"127.0.0.1",
"::1",
"*.local",
".local",
"169.254.0.0/16",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
}, ",")
envVars = append(envVars,
"NO_PROXY="+noProxy,
"no_proxy="+noProxy,
)
if httpPort > 0 {
proxyURL := "http://localhost:" + itoa(httpPort)
envVars = append(envVars,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
)
}
if socksPort > 0 {
socksURL := "socks5h://localhost:" + itoa(socksPort)
envVars = append(envVars,
"ALL_PROXY="+socksURL,
"all_proxy="+socksURL,
"FTP_PROXY="+socksURL,
"ftp_proxy="+socksURL,
)
// Git SSH through SOCKS
envVars = append(envVars,
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:"+itoa(socksPort)+" %h %p'",
)
}
return envVars
}
// EncodeSandboxedCommand encodes a command for sandbox monitoring.
func EncodeSandboxedCommand(command string) string {
if len(command) > 100 {
command = command[:100]
}
return base64.StdEncoding.EncodeToString([]byte(command))
}
// DecodeSandboxedCommand decodes a base64-encoded command.
func DecodeSandboxedCommand(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
return string(data), nil
}
func itoa(n int) string {
return strconv.Itoa(n)
}

39
pkg/fence/fence.go Normal file
View File

@@ -0,0 +1,39 @@
// Package fence provides a public API for sandboxing commands.
package fence
import (
"github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/sandbox"
)
// Config is the configuration for fence.
type Config = config.Config
// NetworkConfig defines network restrictions.
type NetworkConfig = config.NetworkConfig
// FilesystemConfig defines filesystem restrictions.
type FilesystemConfig = config.FilesystemConfig
// Manager handles sandbox initialization and command wrapping.
type Manager = sandbox.Manager
// NewManager creates a new sandbox manager.
func NewManager(cfg *Config, debug bool) *Manager {
return sandbox.NewManager(cfg, debug)
}
// DefaultConfig returns the default configuration with all network blocked.
func DefaultConfig() *Config {
return config.Default()
}
// LoadConfig loads configuration from a file.
func LoadConfig(path string) (*Config, error) {
return config.Load(path)
}
// DefaultConfigPath returns the default config file path.
func DefaultConfigPath() string {
return config.DefaultConfigPath()
}