commit c02c91f051ec24a4f35417a860380c52fcdfa01c Author: JY Tan Date: Thu Dec 18 13:14:07 2025 -0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2afc9ec --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ac2e317 --- /dev/null +++ b/ARCHITECTURE.md @@ -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
(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 +│ ├── 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
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 | +| 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..634925c --- /dev/null +++ b/README.md @@ -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 diff --git a/cmd/fence/main.go b/cmd/fence/main.go new file mode 100644 index 0000000..629cc40 --- /dev/null +++ b/cmd/fence/main.go @@ -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 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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..047cc42 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b421df6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c989645 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..eb3d8c6 --- /dev/null +++ b/internal/platform/platform.go @@ -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 +} diff --git a/internal/proxy/http.go b/internal/proxy/http.go new file mode 100644 index 0000000..8b463a0 --- /dev/null +++ b/internal/proxy/http.go @@ -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 +} diff --git a/internal/proxy/socks.go b/internal/proxy/socks.go new file mode 100644 index 0000000..dc725d8 --- /dev/null +++ b/internal/proxy/socks.go @@ -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 +} diff --git a/internal/sandbox/dangerous.go b/internal/sandbox/dangerous.go new file mode 100644 index 0000000..2a7d1a1 --- /dev/null +++ b/internal/sandbox/dangerous.go @@ -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 +} diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go new file mode 100644 index 0000000..a85ab3f --- /dev/null +++ b/internal/sandbox/linux.go @@ -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 +} diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go new file mode 100644 index 0000000..98bce0e --- /dev/null +++ b/internal/sandbox/macos.go @@ -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 +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go new file mode 100644 index 0000000..52cc7f0 --- /dev/null +++ b/internal/sandbox/manager.go @@ -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 +} diff --git a/internal/sandbox/utils.go b/internal/sandbox/utils.go new file mode 100644 index 0000000..785533e --- /dev/null +++ b/internal/sandbox/utils.go @@ -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) +} diff --git a/pkg/fence/fence.go b/pkg/fence/fence.go new file mode 100644 index 0000000..ceed945 --- /dev/null +++ b/pkg/fence/fence.go @@ -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() +}