Replace built-in proxies with tun2socks transparent proxying

Remove the built-in HTTP/SOCKS5 proxy servers and domain allowlist/denylist
system. Instead, use tun2socks with a TUN device inside the network namespace
to transparently route all TCP/UDP traffic through an external SOCKS5 proxy.

This enables truly transparent proxying where any binary (Go, static, etc.)
has its traffic routed through the proxy without needing to respect
HTTP_PROXY/ALL_PROXY environment variables. The external proxy handles its
own filtering.

Key changes:
- NetworkConfig: remove AllowedDomains/DeniedDomains/proxy ports, add ProxyURL
- Delete internal/proxy/, internal/templates/, internal/importer/
- Embed tun2socks binary (downloaded at build time via Makefile)
- Replace LinuxBridge with ProxyBridge (single Unix socket to external proxy)
- Inner script sets up TUN device + tun2socks inside network namespace
- Falls back to env-var proxying when TUN is unavailable
- macOS: best-effort env-var proxying to external SOCKS5 proxy
- CLI: remove --template/import, add --proxy flag
- Feature detection: add ip/tun/tun2socks status to --linux-features
This commit is contained in:
2026-02-09 20:41:12 -06:00
parent da5f61e390
commit 9cb65151ee
38 changed files with 588 additions and 4155 deletions

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ coverage.out
cpu.out cpu.out
mem.out mem.out
# Embedded binaries (downloaded at build time)
internal/sandbox/bin/tun2socks-*

View File

@@ -5,88 +5,107 @@ GOTEST=$(GOCMD) test
GOMOD=$(GOCMD) mod GOMOD=$(GOCMD) mod
BINARY_NAME=fence BINARY_NAME=fence
BINARY_UNIX=$(BINARY_NAME)_unix BINARY_UNIX=$(BINARY_NAME)_unix
TUN2SOCKS_VERSION=v2.5.2
TUN2SOCKS_BIN_DIR=internal/sandbox/bin
.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor help .PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor download-tun2socks help
all: build all: build
build: download-tun2socks:
@echo "🔨 Building $(BINARY_NAME)..." @echo "Downloading tun2socks $(TUN2SOCKS_VERSION)..."
@mkdir -p $(TUN2SOCKS_BIN_DIR)
@curl -sL "https://github.com/xjasonlyu/tun2socks/releases/download/$(TUN2SOCKS_VERSION)/tun2socks-linux-amd64.zip" -o /tmp/tun2socks-linux-amd64.zip
@unzip -o -q /tmp/tun2socks-linux-amd64.zip -d /tmp/tun2socks-amd64
@mv /tmp/tun2socks-amd64/tun2socks-linux-amd64 $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-amd64
@chmod +x $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-amd64
@rm -rf /tmp/tun2socks-linux-amd64.zip /tmp/tun2socks-amd64
@curl -sL "https://github.com/xjasonlyu/tun2socks/releases/download/$(TUN2SOCKS_VERSION)/tun2socks-linux-arm64.zip" -o /tmp/tun2socks-linux-arm64.zip
@unzip -o -q /tmp/tun2socks-linux-arm64.zip -d /tmp/tun2socks-arm64
@mv /tmp/tun2socks-arm64/tun2socks-linux-arm64 $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-arm64
@chmod +x $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-arm64
@rm -rf /tmp/tun2socks-linux-arm64.zip /tmp/tun2socks-arm64
@echo "tun2socks binaries downloaded to $(TUN2SOCKS_BIN_DIR)/"
build: download-tun2socks
@echo "Building $(BINARY_NAME)..."
$(GOBUILD) -o $(BINARY_NAME) -v ./cmd/fence $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/fence
build-ci: build-ci: download-tun2socks
@echo "🏗️ CI: Building $(BINARY_NAME) with version info..." @echo "CI: Building $(BINARY_NAME) with version info..."
$(eval VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")) $(eval VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev"))
$(eval BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')) $(eval BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ'))
$(eval GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")) $(eval GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown"))
$(GOBUILD) -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)" -o $(BINARY_NAME) -v ./cmd/fence $(GOBUILD) -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)" -o $(BINARY_NAME) -v ./cmd/fence
test: test:
@echo "🧪 Running tests..." @echo "Running tests..."
$(GOTEST) -v ./... $(GOTEST) -v ./...
test-ci: test-ci:
@echo "🧪 CI: Running tests with coverage..." @echo "CI: Running tests with coverage..."
$(GOTEST) -v -race -coverprofile=coverage.out ./... $(GOTEST) -v -race -coverprofile=coverage.out ./...
clean: clean:
@echo "🧹 Cleaning..." @echo "Cleaning..."
$(GOCLEAN) $(GOCLEAN)
rm -f $(BINARY_NAME) rm -f $(BINARY_NAME)
rm -f $(BINARY_UNIX) rm -f $(BINARY_UNIX)
rm -f coverage.out rm -f coverage.out
rm -f $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-*
deps: deps:
@echo "📦 Downloading dependencies..." @echo "Downloading dependencies..."
$(GOMOD) download $(GOMOD) download
$(GOMOD) tidy $(GOMOD) tidy
build-linux: build-linux: download-tun2socks
@echo "🐧 Building for Linux..." @echo "Building for Linux..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/fence CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/fence
build-darwin: build-darwin:
@echo "🍎 Building for macOS..." @echo "Building for macOS..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_NAME)_darwin -v ./cmd/fence CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_NAME)_darwin -v ./cmd/fence
install-lint-tools: install-lint-tools:
@echo "📦 Installing linting tools..." @echo "Installing linting tools..."
go install mvdan.cc/gofumpt@latest go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
@echo "Linting tools installed" @echo "Linting tools installed"
setup: deps install-lint-tools setup: deps install-lint-tools
@echo "Development environment ready" @echo "Development environment ready"
setup-ci: deps install-lint-tools setup-ci: deps install-lint-tools
@echo "CI environment ready" @echo "CI environment ready"
run: build run: build
./$(BINARY_NAME) ./$(BINARY_NAME)
fmt: fmt:
@echo "📝 Formatting code..." @echo "Formatting code..."
gofumpt -w . gofumpt -w .
lint: lint:
@echo "🔍 Linting code..." @echo "Linting code..."
golangci-lint run --allow-parallel-runners golangci-lint run --allow-parallel-runners
release: release:
@echo "🚀 Creating patch release..." @echo "Creating patch release..."
./scripts/release.sh patch ./scripts/release.sh patch
release-minor: release-minor:
@echo "🚀 Creating minor release..." @echo "Creating minor release..."
./scripts/release.sh minor ./scripts/release.sh minor
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " all - build (default)" @echo " all - build (default)"
@echo " build - Build the binary" @echo " build - Build the binary (downloads tun2socks if needed)"
@echo " build-ci - Build for CI with version info" @echo " build-ci - Build for CI with version info"
@echo " build-linux - Build for Linux" @echo " build-linux - Build for Linux"
@echo " build-darwin - Build for macOS" @echo " build-darwin - Build for macOS"
@echo " download-tun2socks - Download tun2socks binaries for embedding"
@echo " test - Run tests" @echo " test - Run tests"
@echo " test-ci - Run tests for CI with coverage" @echo " test-ci - Run tests for CI with coverage"
@echo " clean - Clean build artifacts" @echo " clean - Clean build artifacts"
@@ -100,4 +119,3 @@ help:
@echo " release - Create patch release (v0.0.X)" @echo " release - Create patch release (v0.0.X)"
@echo " release-minor - Create minor release (v0.X.0)" @echo " release-minor - Create minor release (v0.X.0)"
@echo " help - Show this help" @echo " help - Show this help"

View File

@@ -2,22 +2,17 @@
package main package main
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/importer"
"github.com/Use-Tusk/fence/internal/platform" "github.com/Use-Tusk/fence/internal/platform"
"github.com/Use-Tusk/fence/internal/sandbox" "github.com/Use-Tusk/fence/internal/sandbox"
"github.com/Use-Tusk/fence/internal/templates"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -32,8 +27,7 @@ var (
debug bool debug bool
monitor bool monitor bool
settingsPath string settingsPath string
templateName string proxyURL string
listTemplates bool
cmdString string cmdString string
exposePorts []string exposePorts []string
exitCode int exitCode int
@@ -55,25 +49,29 @@ func main() {
Long: `fence is a command-line tool that runs commands in a sandboxed environment Long: `fence is a command-line tool that runs commands in a sandboxed environment
with network and filesystem restrictions. with network and filesystem restrictions.
By default, all network access is blocked. Configure allowed domains in By default, all network access is blocked. Use --proxy to route traffic through
~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS) an external SOCKS5 proxy, or configure a proxy URL in your settings file at
or pass a settings file with --settings, or use a built-in template with --template. ~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS).
On Linux, fence uses tun2socks for truly transparent proxying: all TCP/UDP traffic
from any binary is captured at the kernel level via a TUN device and forwarded
through the external SOCKS5 proxy. No application awareness needed.
On macOS, fence uses environment variables (best-effort) to direct traffic
to the proxy.
Examples: Examples:
fence curl https://example.com # Will be blocked (no domains allowed) fence -- curl https://example.com # Blocked (no proxy)
fence -- curl -s https://example.com # Use -- to separate fence flags from command fence --proxy socks5://localhost:1080 -- curl https://example.com # Via proxy
fence -c "echo hello && ls" # Run with shell expansion fence -- curl -s https://example.com # Use -- to separate flags
fence -c "echo hello && ls" # Run with shell expansion
fence --settings config.json npm install fence --settings config.json npm install
fence -t npm-install npm install # Use built-in npm-install template fence -p 3000 -c "npm run dev" # Expose port 3000
fence -t ai-coding-agents -- agent-cmd # Use AI coding agents template
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
fence --list-templates # Show available built-in templates
Configuration file format: Configuration file format:
{ {
"network": { "network": {
"allowedDomains": ["github.com", "*.npmjs.org"], "proxyUrl": "socks5://localhost:1080"
"deniedDomains": []
}, },
"filesystem": { "filesystem": {
"denyRead": [], "denyRead": [],
@@ -91,10 +89,9 @@ Configuration file format:
} }
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)") rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)") rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
rootCmd.Flags().StringVarP(&templateName, "template", "t", "", "Use built-in template (e.g., ai-coding-agents, npm-install)") rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (e.g., socks5://localhost:1080)")
rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)") 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().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information") rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
@@ -102,7 +99,6 @@ Configuration file format:
rootCmd.Flags().SetInterspersed(true) rootCmd.Flags().SetInterspersed(true)
rootCmd.AddCommand(newImportCmd())
rootCmd.AddCommand(newCompletionCmd(rootCmd)) rootCmd.AddCommand(newCompletionCmd(rootCmd))
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
@@ -126,11 +122,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
return nil return nil
} }
if listTemplates {
printTemplates()
return nil
}
var command string var command string
switch { switch {
case cmdString != "": case cmdString != "":
@@ -158,29 +149,16 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports) fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports)
} }
// Load config: template > settings file > default path // Load config: settings file > default path > default config
var cfg *config.Config var cfg *config.Config
var err error var err error
switch { switch {
case templateName != "":
cfg, err = templates.Load(templateName)
if err != nil {
return fmt.Errorf("failed to load template: %w\nUse --list-templates to see available templates", err)
}
if debug {
fmt.Fprintf(os.Stderr, "[fence] Using template: %s\n", templateName)
}
case settingsPath != "": case settingsPath != "":
cfg, err = config.Load(settingsPath) cfg, err = config.Load(settingsPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
absPath, _ := filepath.Abs(settingsPath)
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(absPath))
if err != nil {
return fmt.Errorf("failed to resolve extends: %w", err)
}
default: default:
configPath := config.DefaultConfigPath() configPath := config.DefaultConfigPath()
cfg, err = config.Load(configPath) cfg, err = config.Load(configPath)
@@ -192,14 +170,14 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath) fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
} }
cfg = config.Default() cfg = config.Default()
} else {
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(configPath))
if err != nil {
return fmt.Errorf("failed to resolve extends: %w", err)
}
} }
} }
// CLI --proxy flag overrides config
if proxyURL != "" {
cfg.Network.ProxyURL = proxyURL
}
manager := sandbox.NewManager(cfg, debug, monitor) manager := sandbox.NewManager(cfg, debug, monitor)
manager.SetExposedPorts(ports) manager.SetExposedPorts(ports)
defer manager.Cleanup() defer manager.Cleanup()
@@ -263,12 +241,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
} }
} }
// Note: Landlock is NOT applied here because:
// 1. The sandboxed command is already running (Landlock only affects future children)
// 2. Proper Landlock integration requires applying restrictions inside the sandbox
// For now, filesystem isolation relies on bwrap mount namespaces.
// Landlock code exists for future integration (e.g., via a wrapper binary).
go func() { go func() {
sigCount := 0 sigCount := 0
for sig := range sigChan { for sig := range sigChan {
@@ -298,136 +270,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
return nil return nil
} }
// newImportCmd creates the import subcommand.
func newImportCmd() *cobra.Command {
var (
claudeMode bool
inputFile string
outputFile string
saveFlag bool
forceFlag bool
extendTmpl string
noExtend bool
)
cmd := &cobra.Command{
Use: "import",
Short: "Import settings from other tools",
Long: `Import permission settings from other tools and convert them to fence config.
Currently supported sources:
--claude Import from Claude Code settings
By default, imports extend the "code" template which provides sensible defaults
for network access (npm, GitHub, LLM providers) and filesystem protections.
Use --no-extend for a minimal config, or --extend to choose a different template.
Examples:
# Preview import (prints JSON to stdout)
fence import --claude
# Save to the default config path
# Linux: ~/.config/fence/fence.json
# macOS: ~/Library/Application Support/fence/fence.json
fence import --claude --save
# Save to a specific output file
fence import --claude -o ./fence.json
# Import from a specific Claude Code settings file
fence import --claude -f ~/.claude/settings.json --save
# Import without extending any template (minimal config)
fence import --claude --no-extend --save
# Import and extend a different template
fence import --claude --extend local-dev-server --save`,
RunE: func(cmd *cobra.Command, args []string) error {
if !claudeMode {
return fmt.Errorf("no import source specified. Use --claude to import from Claude Code")
}
opts := importer.DefaultImportOptions()
if noExtend {
opts.Extends = ""
} else if extendTmpl != "" {
opts.Extends = extendTmpl
}
result, err := importer.ImportFromClaude(inputFile, opts)
if err != nil {
return fmt.Errorf("failed to import Claude settings: %w", err)
}
for _, warning := range result.Warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
if len(result.Warnings) > 0 {
fmt.Fprintln(os.Stderr)
}
// Determine output destination
var destPath string
if saveFlag {
destPath = config.DefaultConfigPath()
} else if outputFile != "" {
destPath = outputFile
}
if destPath != "" {
if !forceFlag {
if _, err := os.Stat(destPath); err == nil {
fmt.Printf("File %q already exists. Overwrite? [y/N] ", destPath)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Aborted.")
return nil
}
}
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := importer.WriteConfig(result.Config, destPath); err != nil {
return err
}
fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
fmt.Printf("Written to %q\n", destPath)
} else {
// Print clean JSON to stdout, helpful info to stderr (don't interfere with piping)
data, err := importer.MarshalConfigJSON(result.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
fmt.Println(string(data))
if result.Config.Extends != "" {
fmt.Fprintf(os.Stderr, "\n# Extends %q - inherited rules not shown\n", result.Config.Extends)
}
fmt.Fprintf(os.Stderr, "# Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
fmt.Fprintf(os.Stderr, "# Use --save to write to the default config path\n")
}
return nil
},
}
cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings")
cmd.Flags().StringVarP(&inputFile, "file", "f", "", "Path to settings file (default: ~/.claude/settings.json for --claude)")
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path")
cmd.Flags().BoolVar(&saveFlag, "save", false, "Save to the default config path")
cmd.Flags().BoolVarP(&forceFlag, "force", "y", false, "Overwrite existing file without prompting")
cmd.Flags().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)")
cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)")
cmd.MarkFlagsMutuallyExclusive("extend", "no-extend")
cmd.MarkFlagsMutuallyExclusive("save", "output")
return cmd
}
// newCompletionCmd creates the completion subcommand for shell completions. // newCompletionCmd creates the completion subcommand for shell completions.
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -473,18 +315,6 @@ ${fpath[1]}/_fence for zsh, ~/.config/fish/completions/fence.fish for fish).
return cmd return cmd
} }
// printTemplates prints all available templates to stdout.
func printTemplates() {
fmt.Println("Available templates:")
fmt.Println()
for _, t := range templates.List() {
fmt.Printf(" %-20s %s\n", t.Name, t.Description)
}
fmt.Println()
fmt.Println("Usage: fence -t <template> <command>")
fmt.Println("Example: fence -t code -- code")
}
// runLandlockWrapper runs in "wrapper mode" inside the sandbox. // runLandlockWrapper runs in "wrapper mode" inside the sandbox.
// It applies Landlock restrictions and then execs the user command. // It applies Landlock restrictions and then execs the user command.
// Usage: fence --landlock-apply [--debug] -- <command...> // Usage: fence --landlock-apply [--debug] -- <command...>

5
go.mod
View File

@@ -5,16 +5,11 @@ go 1.25
require ( require (
github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.8.4
github.com/things-go/go-socks5 v0.0.5
github.com/tidwall/jsonc v0.3.2 github.com/tidwall/jsonc v0.3.2
golang.org/x/sys v0.39.0 golang.org/x/sys v0.39.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

12
go.sum
View File

@@ -1,28 +1,16 @@
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/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 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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=
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -25,14 +26,11 @@ type Config struct {
// NetworkConfig defines network restrictions. // NetworkConfig defines network restrictions.
type NetworkConfig struct { type NetworkConfig struct {
AllowedDomains []string `json:"allowedDomains"` ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DeniedDomains []string `json:"deniedDomains"`
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"` AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"` AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"` AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
} }
// FilesystemConfig defines filesystem restrictions. // FilesystemConfig defines filesystem restrictions.
@@ -106,13 +104,10 @@ var DefaultDeniedCommands = []string{
"nsenter", "nsenter",
} }
// Default returns the default configuration with all network blocked. // Default returns the default configuration with all network blocked (no proxy = no network).
func Default() *Config { func Default() *Config {
return &Config{ return &Config{
Network: NetworkConfig{ Network: NetworkConfig{},
AllowedDomains: []string{},
DeniedDomains: []string{},
},
Filesystem: FilesystemConfig{ Filesystem: FilesystemConfig{
DenyRead: []string{}, DenyRead: []string{},
AllowWrite: []string{}, AllowWrite: []string{},
@@ -196,14 +191,9 @@ func Load(path string) (*Config, error) {
// Validate validates the configuration. // Validate validates the configuration.
func (c *Config) Validate() error { func (c *Config) Validate() error {
for _, domain := range c.Network.AllowedDomains { if c.Network.ProxyURL != "" {
if err := validateDomainPattern(domain); err != nil { if err := validateProxyURL(c.Network.ProxyURL); err != nil {
return fmt.Errorf("invalid allowed domain %q: %w", domain, err) return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
}
}
for _, domain := range c.Network.DeniedDomains {
if err := validateDomainPattern(domain); err != nil {
return fmt.Errorf("invalid denied domain %q: %w", domain, err)
} }
} }
@@ -253,46 +243,21 @@ func (c *CommandConfig) UseDefaultDeniedCommands() bool {
return c.UseDefaults == nil || *c.UseDefaults return c.UseDefaults == nil || *c.UseDefaults
} }
func validateDomainPattern(pattern string) error { // validateProxyURL validates a SOCKS5 proxy URL.
if pattern == "localhost" { func validateProxyURL(proxyURL string) error {
return nil u, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
} }
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") || strings.Contains(pattern, ":") { return errors.New("proxy URL must use socks5:// or socks5h:// scheme")
return errors.New("domain pattern cannot contain protocol, path, or port")
} }
if u.Hostname() == "" {
// Handle wildcard patterns return errors.New("proxy URL must include a hostname")
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
} }
if u.Port() == "" {
// Reject other uses of wildcards return errors.New("proxy URL must include a port")
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 return nil
} }
@@ -332,26 +297,6 @@ func isIPv6Pattern(pattern string) bool {
return colonCount >= 2 return colonCount >= 2
} }
// MatchesDomain checks if a hostname matches a domain pattern.
func MatchesDomain(hostname, pattern string) bool {
hostname = strings.ToLower(hostname)
pattern = strings.ToLower(pattern)
// "*" matches all domains
if pattern == "*" {
return true
}
// Wildcard pattern like *.example.com
if strings.HasPrefix(pattern, "*.") {
baseDomain := pattern[2:]
return strings.HasSuffix(hostname, "."+baseDomain)
}
// Exact match
return hostname == pattern
}
// MatchesHost checks if a hostname matches an SSH host pattern. // MatchesHost checks if a hostname matches an SSH host pattern.
// SSH host patterns support wildcards anywhere in the pattern. // SSH host patterns support wildcards anywhere in the pattern.
func MatchesHost(hostname, pattern string) bool { func MatchesHost(hostname, pattern string) bool {
@@ -440,9 +385,10 @@ func Merge(base, override *Config) *Config {
AllowPty: base.AllowPty || override.AllowPty, AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{ Network: NetworkConfig{
// ProxyURL: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
// Append slices (base first, then override additions) // Append slices (base first, then override additions)
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets), AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
// Boolean fields: override wins if set, otherwise base // Boolean fields: override wins if set, otherwise base
@@ -451,10 +397,6 @@ func Merge(base, override *Config) *Config {
// Pointer fields: override wins if set, otherwise base // Pointer fields: override wins if set, otherwise base
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound), AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
// Port fields: override wins if non-zero
HTTPProxyPort: mergeInt(base.Network.HTTPProxyPort, override.Network.HTTPProxyPort),
SOCKSProxyPort: mergeInt(base.Network.SOCKSProxyPort, override.Network.SOCKSProxyPort),
}, },
Filesystem: FilesystemConfig{ Filesystem: FilesystemConfig{
@@ -531,9 +473,9 @@ func mergeOptionalBool(base, override *bool) *bool {
return base return base
} }
// mergeInt returns override if non-zero, otherwise base. // mergeString returns override if non-empty, otherwise base.
func mergeInt(base, override int) int { func mergeString(base, override string) string {
if override != 0 { if override != "" {
return override return override
} }
return base return base

View File

@@ -6,72 +6,6 @@ import (
"testing" "testing"
) )
func TestValidateDomainPattern(t *testing.T) {
tests := []struct {
name string
pattern string
wantErr bool
}{
// Valid patterns
{"valid domain", "example.com", false},
{"valid subdomain", "api.example.com", false},
{"valid wildcard", "*.example.com", false},
{"valid wildcard subdomain", "*.api.example.com", false},
{"localhost", "localhost", false},
// Invalid patterns
{"protocol included", "https://example.com", true},
{"path included", "example.com/path", true},
{"port included", "example.com:443", true},
{"wildcard too broad", "*.com", true},
{"invalid wildcard position", "example.*.com", true},
{"trailing wildcard", "example.com.*", true},
{"leading dot", ".example.com", true},
{"trailing dot", "example.com.", true},
{"no TLD", "example", true},
{"empty wildcard domain part", "*.", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDomainPattern(tt.pattern)
if (err != nil) != tt.wantErr {
t.Errorf("validateDomainPattern(%q) error = %v, wantErr %v", tt.pattern, err, tt.wantErr)
}
})
}
}
func TestMatchesDomain(t *testing.T) {
tests := []struct {
name string
hostname string
pattern string
want bool
}{
// Exact matches
{"exact match", "example.com", "example.com", true},
{"exact match case insensitive", "Example.COM", "example.com", true},
{"exact no match", "other.com", "example.com", false},
// Wildcard matches
{"wildcard match subdomain", "api.example.com", "*.example.com", true},
{"wildcard match deep subdomain", "deep.api.example.com", "*.example.com", true},
{"wildcard no match base domain", "example.com", "*.example.com", false},
{"wildcard no match different domain", "api.other.com", "*.example.com", false},
{"wildcard case insensitive", "API.Example.COM", "*.example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MatchesDomain(tt.hostname, tt.pattern)
if got != tt.want {
t.Errorf("MatchesDomain(%q, %q) = %v, want %v", tt.hostname, tt.pattern, got, tt.want)
}
})
}
}
func TestConfigValidate(t *testing.T) { func TestConfigValidate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -84,29 +18,55 @@ func TestConfigValidate(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
name: "valid config with domains", name: "valid config with proxy",
config: Config{ config: Config{
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"example.com", "*.github.com"}, ProxyURL: "socks5://localhost:1080",
DeniedDomains: []string{"blocked.com"},
}, },
}, },
wantErr: false, wantErr: false,
}, },
{ {
name: "invalid allowed domain", name: "valid socks5h proxy",
config: Config{ config: Config{
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"https://example.com"}, ProxyURL: "socks5h://proxy.example.com:1080",
},
},
wantErr: false,
},
{
name: "invalid proxy - wrong scheme",
config: Config{
Network: NetworkConfig{
ProxyURL: "http://localhost:1080",
}, },
}, },
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid denied domain", name: "invalid proxy - no port",
config: Config{ config: Config{
Network: NetworkConfig{ Network: NetworkConfig{
DeniedDomains: []string{"*.com"}, ProxyURL: "socks5://localhost",
},
},
wantErr: true,
},
{
name: "invalid proxy - no host",
config: Config{
Network: NetworkConfig{
ProxyURL: "socks5://:1080",
},
},
wantErr: true,
},
{
name: "invalid proxy - not a URL",
config: Config{
Network: NetworkConfig{
ProxyURL: "not-a-url",
}, },
}, },
wantErr: true, wantErr: true,
@@ -164,11 +124,8 @@ func TestDefault(t *testing.T) {
if cfg == nil { if cfg == nil {
t.Fatal("Default() returned nil") t.Fatal("Default() returned nil")
} }
if cfg.Network.AllowedDomains == nil { if cfg.Network.ProxyURL != "" {
t.Error("AllowedDomains should not be nil") t.Error("ProxyURL should be empty by default")
}
if cfg.Network.DeniedDomains == nil {
t.Error("DeniedDomains should not be nil")
} }
if cfg.Filesystem.DenyRead == nil { if cfg.Filesystem.DenyRead == nil {
t.Error("DenyRead should not be nil") t.Error("DenyRead should not be nil")
@@ -222,21 +179,18 @@ func TestLoad(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
name: "valid config", name: "valid config with proxy",
setup: func(dir string) string { setup: func(dir string) string {
path := filepath.Join(dir, "valid.json") path := filepath.Join(dir, "valid.json")
content := `{"network":{"allowedDomains":["example.com"]}}` content := `{"network":{"proxyUrl":"socks5://localhost:1080"}}`
_ = os.WriteFile(path, []byte(content), 0o600) _ = os.WriteFile(path, []byte(content), 0o600)
return path return path
}, },
wantNil: false, wantNil: false,
wantErr: false, wantErr: false,
checkConfig: func(t *testing.T, cfg *Config) { checkConfig: func(t *testing.T, cfg *Config) {
if len(cfg.Network.AllowedDomains) != 1 { if cfg.Network.ProxyURL != "socks5://localhost:1080" {
t.Errorf("expected 1 allowed domain, got %d", len(cfg.Network.AllowedDomains)) t.Errorf("expected socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
}
if cfg.Network.AllowedDomains[0] != "example.com" {
t.Errorf("expected example.com, got %s", cfg.Network.AllowedDomains[0])
} }
}, },
}, },
@@ -251,10 +205,10 @@ func TestLoad(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid domain in config", name: "invalid proxy URL in config",
setup: func(dir string) string { setup: func(dir string) string {
path := filepath.Join(dir, "invalid_domain.json") path := filepath.Join(dir, "invalid_proxy.json")
content := `{"network":{"allowedDomains":["*.com"]}}` content := `{"network":{"proxyUrl":"http://localhost:1080"}}`
_ = os.WriteFile(path, []byte(content), 0o600) _ = os.WriteFile(path, []byte(content), 0o600)
return path return path
}, },
@@ -307,15 +261,15 @@ func TestMerge(t *testing.T) {
override := &Config{ override := &Config{
AllowPty: true, AllowPty: true,
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"example.com"}, ProxyURL: "socks5://localhost:1080",
}, },
} }
result := Merge(nil, override) result := Merge(nil, override)
if !result.AllowPty { if !result.AllowPty {
t.Error("expected AllowPty to be true") t.Error("expected AllowPty to be true")
} }
if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "example.com" { if result.Network.ProxyURL != "socks5://localhost:1080" {
t.Error("expected AllowedDomains to be [example.com]") t.Error("expected ProxyURL to be socks5://localhost:1080")
} }
if result.Extends != "" { if result.Extends != "" {
t.Error("expected Extends to be cleared") t.Error("expected Extends to be cleared")
@@ -326,15 +280,15 @@ func TestMerge(t *testing.T) {
base := &Config{ base := &Config{
AllowPty: true, AllowPty: true,
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"example.com"}, ProxyURL: "socks5://localhost:1080",
}, },
} }
result := Merge(base, nil) result := Merge(base, nil)
if !result.AllowPty { if !result.AllowPty {
t.Error("expected AllowPty to be true") t.Error("expected AllowPty to be true")
} }
if len(result.Network.AllowedDomains) != 1 { if result.Network.ProxyURL != "socks5://localhost:1080" {
t.Error("expected AllowedDomains to be [example.com]") t.Error("expected ProxyURL to be preserved")
} }
}) })
@@ -345,47 +299,37 @@ func TestMerge(t *testing.T) {
} }
}) })
t.Run("merge allowed domains", func(t *testing.T) { t.Run("proxy URL override wins", func(t *testing.T) {
base := &Config{ base := &Config{
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"github.com", "api.github.com"}, ProxyURL: "socks5://base:1080",
}, },
} }
override := &Config{ override := &Config{
Extends: "base-template",
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"private-registry.company.com"}, ProxyURL: "socks5://override:1080",
}, },
} }
result := Merge(base, override) result := Merge(base, override)
// Should have all three domains if result.Network.ProxyURL != "socks5://override:1080" {
if len(result.Network.AllowedDomains) != 3 { t.Errorf("expected override ProxyURL, got %s", result.Network.ProxyURL)
t.Errorf("expected 3 allowed domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
// Extends should be cleared
if result.Extends != "" {
t.Errorf("expected Extends to be cleared, got %q", result.Extends)
} }
}) })
t.Run("deduplicate merged domains", func(t *testing.T) { t.Run("proxy URL base preserved when override empty", func(t *testing.T) {
base := &Config{ base := &Config{
Network: NetworkConfig{ Network: NetworkConfig{
AllowedDomains: []string{"github.com", "example.com"}, ProxyURL: "socks5://base:1080",
}, },
} }
override := &Config{ override := &Config{
Network: NetworkConfig{ Network: NetworkConfig{},
AllowedDomains: []string{"github.com", "new.com"},
},
} }
result := Merge(base, override) result := Merge(base, override)
// Should deduplicate if result.Network.ProxyURL != "socks5://base:1080" {
if len(result.Network.AllowedDomains) != 3 { t.Errorf("expected base ProxyURL, got %s", result.Network.ProxyURL)
t.Errorf("expected 3 domains (deduped), got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
} }
}) })
@@ -484,51 +428,6 @@ func TestMerge(t *testing.T) {
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead) t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
} }
}) })
t.Run("merge defaultDenyRead from override", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: false,
},
}
override := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: true,
AllowRead: []string{"/home/user/project"},
},
}
result := Merge(base, override)
if !result.Filesystem.DefaultDenyRead {
t.Error("expected DefaultDenyRead to be true (from override)")
}
if len(result.Filesystem.AllowRead) != 1 {
t.Errorf("expected 1 allowRead path, got %d", len(result.Filesystem.AllowRead))
}
})
t.Run("override ports", func(t *testing.T) {
base := &Config{
Network: NetworkConfig{
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
},
}
override := &Config{
Network: NetworkConfig{
HTTPProxyPort: 9090, // override
// SOCKSProxyPort not set, should keep base
},
}
result := Merge(base, override)
if result.Network.HTTPProxyPort != 9090 {
t.Errorf("expected HTTPProxyPort 9090, got %d", result.Network.HTTPProxyPort)
}
if result.Network.SOCKSProxyPort != 1080 {
t.Errorf("expected SOCKSProxyPort 1080, got %d", result.Network.SOCKSProxyPort)
}
})
} }
func boolPtr(b bool) *bool { func boolPtr(b bool) *bool {
@@ -741,3 +640,30 @@ func TestMergeSSHConfig(t *testing.T) {
} }
}) })
} }
func TestValidateProxyURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
{"valid socks5", "socks5://localhost:1080", false},
{"valid socks5h", "socks5h://proxy.example.com:1080", false},
{"valid socks5 with ip", "socks5://192.168.1.1:1080", false},
{"http scheme", "http://localhost:1080", true},
{"https scheme", "https://localhost:1080", true},
{"no port", "socks5://localhost", true},
{"no host", "socks5://:1080", true},
{"not a URL", "not-a-url", true},
{"empty", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateProxyURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("validateProxyURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
}
})
}
}

View File

@@ -1,444 +0,0 @@
// Package importer provides functionality to import settings from other tools.
package importer
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Use-Tusk/fence/internal/config"
"github.com/tidwall/jsonc"
)
// ClaudeSettings represents the Claude Code settings.json structure.
type ClaudeSettings struct {
Permissions ClaudePermissions `json:"permissions"`
}
// ClaudePermissions represents the permissions block in Claude Code settings.
type ClaudePermissions struct {
Allow []string `json:"allow"`
Deny []string `json:"deny"`
Ask []string `json:"ask"`
}
// ClaudeSettingsPaths returns the standard paths where Claude Code stores settings.
func ClaudeSettingsPaths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
paths := []string{
filepath.Join(home, ".claude", "settings.json"),
}
// Also check project-level settings in current directory
cwd, err := os.Getwd()
if err == nil {
paths = append(paths,
filepath.Join(cwd, ".claude", "settings.json"),
filepath.Join(cwd, ".claude", "settings.local.json"),
)
}
return paths
}
// DefaultClaudeSettingsPath returns the default user-level Claude settings path.
func DefaultClaudeSettingsPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude", "settings.json")
}
// LoadClaudeSettings loads Claude Code settings from a file.
func LoadClaudeSettings(path string) (*ClaudeSettings, error) {
data, err := os.ReadFile(path) //nolint:gosec // user-provided path - intentional
if err != nil {
return nil, fmt.Errorf("failed to read Claude settings: %w", err)
}
// Handle empty file
if len(strings.TrimSpace(string(data))) == 0 {
return &ClaudeSettings{}, nil
}
var settings ClaudeSettings
if err := json.Unmarshal(jsonc.ToJSON(data), &settings); err != nil {
return nil, fmt.Errorf("invalid JSON in Claude settings: %w", err)
}
return &settings, nil
}
// ConvertClaudeToFence converts Claude Code settings to a fence config.
func ConvertClaudeToFence(settings *ClaudeSettings) *config.Config {
cfg := config.Default()
// Process allow rules
for _, rule := range settings.Permissions.Allow {
processClaudeRule(rule, cfg, true)
}
// Process deny rules
for _, rule := range settings.Permissions.Deny {
processClaudeRule(rule, cfg, false)
}
// Process ask rules (treat as deny for fence, since fence doesn't have interactive prompts)
// Users can review and move to allow if needed
for _, rule := range settings.Permissions.Ask {
processClaudeRule(rule, cfg, false)
}
return cfg
}
// bashPattern matches Bash permission rules like "Bash(npm run test:*)" or "Bash(curl:*)"
var bashPattern = regexp.MustCompile(`^Bash\((.+)\)$`)
// readPattern matches Read permission rules like "Read(./.env)" or "Read(./secrets/**)"
var readPattern = regexp.MustCompile(`^Read\((.+)\)$`)
// writePattern matches Write permission rules like "Write(./output/**)"
var writePattern = regexp.MustCompile(`^Write\((.+)\)$`)
// editPattern matches Edit permission rules (similar to Write)
var editPattern = regexp.MustCompile(`^Edit\((.+)\)$`)
// processClaudeRule processes a single Claude permission rule and updates the fence config.
func processClaudeRule(rule string, cfg *config.Config, isAllow bool) {
rule = strings.TrimSpace(rule)
if rule == "" {
return
}
// Handle Bash(command) rules
if matches := bashPattern.FindStringSubmatch(rule); len(matches) == 2 {
cmd := normalizeClaudeCommand(matches[1])
if cmd != "" {
if isAllow {
cfg.Command.Allow = appendUnique(cfg.Command.Allow, cmd)
} else {
cfg.Command.Deny = appendUnique(cfg.Command.Deny, cmd)
}
}
return
}
// Handle Read(path) rules
if matches := readPattern.FindStringSubmatch(rule); len(matches) == 2 {
path := normalizeClaudePath(matches[1])
if path != "" {
if !isAllow {
// Read deny -> filesystem.denyRead
cfg.Filesystem.DenyRead = appendUnique(cfg.Filesystem.DenyRead, path)
}
// Note: fence doesn't have an "allowRead" concept - everything is readable by default
}
return
}
// Handle Write(path) rules
if matches := writePattern.FindStringSubmatch(rule); len(matches) == 2 {
path := normalizeClaudePath(matches[1])
if path != "" {
if isAllow {
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
} else {
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
}
}
return
}
// Handle Edit(path) rules (same as Write)
if matches := editPattern.FindStringSubmatch(rule); len(matches) == 2 {
path := normalizeClaudePath(matches[1])
if path != "" {
if isAllow {
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
} else {
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
}
}
return
}
// Handle bare tool names (e.g., "Read", "Write", "Bash")
// These are global permissions that don't map directly to fence's path-based model
// We skip them as they don't provide actionable path/command restrictions
}
// normalizeClaudeCommand converts Claude's command format to fence format.
// Claude uses "npm:*" style, fence uses "npm" for prefix matching.
func normalizeClaudeCommand(cmd string) string {
cmd = strings.TrimSpace(cmd)
// Handle wildcard patterns like "npm:*" -> "npm"
// Claude uses ":" as separator, fence uses space-separated commands
// Also handles "npm run test:*" -> "npm run test"
cmd = strings.TrimSuffix(cmd, ":*")
return cmd
}
// normalizeClaudePath converts Claude's path format to fence format.
func normalizeClaudePath(path string) string {
path = strings.TrimSpace(path)
// Claude uses ./ prefix for relative paths, fence doesn't require it
// but fence does support it, so we can keep it
// Convert ** glob patterns - both Claude and fence support these
// No conversion needed
return path
}
// appendUnique appends a value to a slice if it's not already present.
func appendUnique(slice []string, value string) []string {
for _, v := range slice {
if v == value {
return slice
}
}
return append(slice, value)
}
// ImportResult contains the result of an import operation.
type ImportResult struct {
Config *config.Config
SourcePath string
RulesImported int
Warnings []string
}
// ImportOptions configures the import behavior.
type ImportOptions struct {
// Extends specifies a template or file to extend. Empty string means no extends.
Extends string
}
// DefaultImportOptions returns the default import options.
// By default, imports extend the "code" template for sensible defaults.
func DefaultImportOptions() ImportOptions {
return ImportOptions{
Extends: "code",
}
}
// ImportFromClaude imports settings from Claude Code and returns a fence config.
// If path is empty, it tries the default Claude settings path.
func ImportFromClaude(path string, opts ImportOptions) (*ImportResult, error) {
if path == "" {
path = DefaultClaudeSettingsPath()
}
if path == "" {
return nil, fmt.Errorf("could not determine Claude settings path")
}
settings, err := LoadClaudeSettings(path)
if err != nil {
return nil, err
}
cfg := ConvertClaudeToFence(settings)
// Set extends if specified
if opts.Extends != "" {
cfg.Extends = opts.Extends
}
result := &ImportResult{
Config: cfg,
SourcePath: path,
RulesImported: len(settings.Permissions.Allow) +
len(settings.Permissions.Deny) +
len(settings.Permissions.Ask),
}
// Add warnings for rules that couldn't be fully converted
for _, rule := range settings.Permissions.Allow {
if isGlobalToolRule(rule) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
}
}
for _, rule := range settings.Permissions.Deny {
if isGlobalToolRule(rule) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
}
}
for _, rule := range settings.Permissions.Ask {
if isGlobalToolRule(rule) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
} else {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Ask rule %q converted to deny (fence doesn't support interactive prompts)", rule))
}
}
return result, nil
}
// isGlobalToolRule checks if a rule is a global tool permission (no path/command specified).
func isGlobalToolRule(rule string) bool {
rule = strings.TrimSpace(rule)
// Global rules are bare tool names without parentheses
return !strings.Contains(rule, "(")
}
// cleanNetworkConfig is used for JSON output with omitempty to skip empty fields.
type cleanNetworkConfig struct {
AllowedDomains []string `json:"allowedDomains,omitempty"`
DeniedDomains []string `json:"deniedDomains,omitempty"`
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"`
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
}
// cleanFilesystemConfig is used for JSON output with omitempty to skip empty fields.
type cleanFilesystemConfig struct {
DenyRead []string `json:"denyRead,omitempty"`
AllowWrite []string `json:"allowWrite,omitempty"`
DenyWrite []string `json:"denyWrite,omitempty"`
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
}
// cleanCommandConfig is used for JSON output with omitempty to skip empty fields.
type cleanCommandConfig struct {
Deny []string `json:"deny,omitempty"`
Allow []string `json:"allow,omitempty"`
UseDefaults *bool `json:"useDefaults,omitempty"`
}
// cleanConfig is used for JSON output with fields in desired order and omitempty.
type cleanConfig struct {
Extends string `json:"extends,omitempty"`
AllowPty bool `json:"allowPty,omitempty"`
Network *cleanNetworkConfig `json:"network,omitempty"`
Filesystem *cleanFilesystemConfig `json:"filesystem,omitempty"`
Command *cleanCommandConfig `json:"command,omitempty"`
}
// MarshalConfigJSON marshals a fence config to clean JSON, omitting empty arrays
// and with fields in a logical order (extends first).
func MarshalConfigJSON(cfg *config.Config) ([]byte, error) {
clean := cleanConfig{
Extends: cfg.Extends,
AllowPty: cfg.AllowPty,
}
// Network config - only include if non-empty
network := cleanNetworkConfig{
AllowedDomains: cfg.Network.AllowedDomains,
DeniedDomains: cfg.Network.DeniedDomains,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: cfg.Network.AllowLocalBinding,
AllowLocalOutbound: cfg.Network.AllowLocalOutbound,
HTTPProxyPort: cfg.Network.HTTPProxyPort,
SOCKSProxyPort: cfg.Network.SOCKSProxyPort,
}
if !isNetworkEmpty(network) {
clean.Network = &network
}
// Filesystem config - only include if non-empty
filesystem := cleanFilesystemConfig{
DenyRead: cfg.Filesystem.DenyRead,
AllowWrite: cfg.Filesystem.AllowWrite,
DenyWrite: cfg.Filesystem.DenyWrite,
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
}
if !isFilesystemEmpty(filesystem) {
clean.Filesystem = &filesystem
}
// Command config - only include if non-empty
command := cleanCommandConfig{
Deny: cfg.Command.Deny,
Allow: cfg.Command.Allow,
UseDefaults: cfg.Command.UseDefaults,
}
if !isCommandEmpty(command) {
clean.Command = &command
}
return json.MarshalIndent(clean, "", " ")
}
func isNetworkEmpty(n cleanNetworkConfig) bool {
return len(n.AllowedDomains) == 0 &&
len(n.DeniedDomains) == 0 &&
len(n.AllowUnixSockets) == 0 &&
!n.AllowAllUnixSockets &&
!n.AllowLocalBinding &&
n.AllowLocalOutbound == nil &&
n.HTTPProxyPort == 0 &&
n.SOCKSProxyPort == 0
}
func isFilesystemEmpty(f cleanFilesystemConfig) bool {
return len(f.DenyRead) == 0 &&
len(f.AllowWrite) == 0 &&
len(f.DenyWrite) == 0 &&
!f.AllowGitConfig
}
func isCommandEmpty(c cleanCommandConfig) bool {
return len(c.Deny) == 0 &&
len(c.Allow) == 0 &&
c.UseDefaults == nil
}
// FormatConfigWithComment returns the config JSON with a comment header
// explaining that values are inherited from the extended template.
func FormatConfigWithComment(cfg *config.Config) (string, error) {
data, err := MarshalConfigJSON(cfg)
if err != nil {
return "", err
}
var output strings.Builder
// Add comment about inherited values if extending a template
if cfg.Extends != "" {
output.WriteString(fmt.Sprintf("// This config extends %q.\n", cfg.Extends))
output.WriteString(fmt.Sprintf("// Network, filesystem, and command rules from %q are inherited.\n", cfg.Extends))
output.WriteString("// Only your additional rules are shown below.\n")
output.WriteString("// Run `fence --list-templates` to see available templates.\n")
}
output.Write(data)
output.WriteByte('\n')
return output.String(), nil
}
// WriteConfig writes a fence config to a file.
func WriteConfig(cfg *config.Config, path string) error {
output, err := FormatConfigWithComment(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(path, []byte(output), 0o644); err != nil { //nolint:gosec // config file permissions
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}

View File

@@ -1,581 +0,0 @@
package importer
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Use-Tusk/fence/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertClaudeToFence(t *testing.T) {
tests := []struct {
name string
settings *ClaudeSettings
wantCmd struct {
allow []string
deny []string
}
wantFS struct {
denyRead []string
allowWrite []string
denyWrite []string
}
}{
{
name: "empty settings",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{},
},
wantCmd: struct {
allow []string
deny []string
}{},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{},
},
{
name: "bash allow rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Bash(npm run lint)",
"Bash(npm run test:*)",
"Bash(git status)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
allow: []string{"npm run lint", "npm run test", "git status"},
},
},
{
name: "bash deny rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Deny: []string{
"Bash(curl:*)",
"Bash(sudo:*)",
"Bash(rm -rf /)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
deny: []string{"curl", "sudo", "rm -rf /"},
},
},
{
name: "read deny rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Deny: []string{
"Read(./.env)",
"Read(./secrets/**)",
"Read(~/.ssh/*)",
},
},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyRead: []string{"./.env", "./secrets/**", "~/.ssh/*"},
},
},
{
name: "write allow rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Write(./output/**)",
"Write(./build)",
},
},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
allowWrite: []string{"./output/**", "./build"},
},
},
{
name: "write deny rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Deny: []string{
"Write(./.git/**)",
"Edit(./package-lock.json)",
},
},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyWrite: []string{"./.git/**", "./package-lock.json"},
},
},
{
name: "ask rules converted to deny",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Ask: []string{
"Write(./config.json)",
"Bash(npm publish)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
deny: []string{"npm publish"},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyWrite: []string{"./config.json"},
},
},
{
name: "global tool rules are skipped",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Read",
"Grep",
"LS",
"Bash(npm run build)", // This should be included
},
Deny: []string{
"Edit",
"Bash(sudo:*)", // This should be included
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
allow: []string{"npm run build"},
deny: []string{"sudo"},
},
},
{
name: "mixed rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Bash(npm install)",
"Bash(npm run:*)",
"Write(./dist/**)",
},
Deny: []string{
"Bash(curl:*)",
"Read(./.env)",
"Write(./.git/**)",
},
Ask: []string{
"Bash(git push)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
allow: []string{"npm install", "npm run"},
deny: []string{"curl", "git push"},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyRead: []string{"./.env"},
allowWrite: []string{"./dist/**"},
denyWrite: []string{"./.git/**"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := ConvertClaudeToFence(tt.settings)
assert.ElementsMatch(t, tt.wantCmd.allow, cfg.Command.Allow, "command.allow mismatch")
assert.ElementsMatch(t, tt.wantCmd.deny, cfg.Command.Deny, "command.deny mismatch")
assert.ElementsMatch(t, tt.wantFS.denyRead, cfg.Filesystem.DenyRead, "filesystem.denyRead mismatch")
assert.ElementsMatch(t, tt.wantFS.allowWrite, cfg.Filesystem.AllowWrite, "filesystem.allowWrite mismatch")
assert.ElementsMatch(t, tt.wantFS.denyWrite, cfg.Filesystem.DenyWrite, "filesystem.denyWrite mismatch")
})
}
}
func TestNormalizeClaudeCommand(t *testing.T) {
tests := []struct {
input string
want string
}{
{"npm:*", "npm"},
{"curl:*", "curl"},
{"npm run test:*", "npm run test"},
{"git status", "git status"},
{"sudo rm -rf", "sudo rm -rf"},
{"", ""},
{" npm ", "npm"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeClaudeCommand(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
func TestLoadClaudeSettings(t *testing.T) {
t.Run("valid settings", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)", "Read"],
"deny": ["Bash(sudo:*)"],
"ask": ["Write"]
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
settings, err := LoadClaudeSettings(settingsPath)
require.NoError(t, err)
assert.Equal(t, []string{"Bash(npm install)", "Read"}, settings.Permissions.Allow)
assert.Equal(t, []string{"Bash(sudo:*)"}, settings.Permissions.Deny)
assert.Equal(t, []string{"Write"}, settings.Permissions.Ask)
})
t.Run("settings with comments (JSONC)", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
// This is a comment
"permissions": {
"allow": ["Bash(npm install)"],
"deny": [], // Another comment
"ask": []
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
settings, err := LoadClaudeSettings(settingsPath)
require.NoError(t, err)
assert.Equal(t, []string{"Bash(npm install)"}, settings.Permissions.Allow)
})
t.Run("empty file", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
err := os.WriteFile(settingsPath, []byte(""), 0o600) //nolint:gosec // test file
require.NoError(t, err)
settings, err := LoadClaudeSettings(settingsPath)
require.NoError(t, err)
assert.NotNil(t, settings)
})
t.Run("file not found", func(t *testing.T) {
_, err := LoadClaudeSettings("/nonexistent/path/settings.json")
assert.Error(t, err)
})
t.Run("invalid json", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
err := os.WriteFile(settingsPath, []byte("not json"), 0o600) //nolint:gosec // test file
require.NoError(t, err)
_, err = LoadClaudeSettings(settingsPath)
assert.Error(t, err)
})
}
func TestImportFromClaude(t *testing.T) {
t.Run("successful import with default extends", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)", "Write(./dist/**)"],
"deny": ["Bash(curl:*)", "Read(./.env)"],
"ask": ["Bash(git push)"]
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
require.NoError(t, err)
assert.Equal(t, settingsPath, result.SourcePath)
assert.Equal(t, 5, result.RulesImported)
assert.Equal(t, "code", result.Config.Extends) // default extends
// Check converted config
assert.Contains(t, result.Config.Command.Allow, "npm install")
assert.Contains(t, result.Config.Command.Deny, "curl")
assert.Contains(t, result.Config.Command.Deny, "git push") // ask -> deny
assert.Contains(t, result.Config.Filesystem.AllowWrite, "./dist/**")
assert.Contains(t, result.Config.Filesystem.DenyRead, "./.env")
})
t.Run("import with no extend", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)"],
"deny": [],
"ask": []
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
opts := ImportOptions{Extends: ""}
result, err := ImportFromClaude(settingsPath, opts)
require.NoError(t, err)
assert.Equal(t, "", result.Config.Extends) // no extends
assert.Contains(t, result.Config.Command.Allow, "npm install")
})
t.Run("import with custom extend", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)"],
"deny": [],
"ask": []
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
opts := ImportOptions{Extends: "local-dev-server"}
result, err := ImportFromClaude(settingsPath, opts)
require.NoError(t, err)
assert.Equal(t, "local-dev-server", result.Config.Extends)
})
t.Run("warnings for global rules", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Read", "Grep", "Bash(npm install)"],
"deny": ["Edit"],
"ask": ["Write"]
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
require.NoError(t, err)
// Should have warnings for global rules: Read, Grep, Edit, Write (all global)
assert.Len(t, result.Warnings, 4)
// Verify the warnings mention the right rules
warningsStr := strings.Join(result.Warnings, " ")
assert.Contains(t, warningsStr, "Read")
assert.Contains(t, warningsStr, "Grep")
assert.Contains(t, warningsStr, "Edit")
assert.Contains(t, warningsStr, "Write")
assert.Contains(t, warningsStr, "skipped")
})
}
func TestWriteConfig(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "fence.json")
cfg := &config.Config{}
cfg.Command.Allow = []string{"npm install"}
cfg.Command.Deny = []string{"curl"}
cfg.Filesystem.DenyRead = []string{"./.env"}
err := WriteConfig(cfg, outputPath)
require.NoError(t, err)
// Verify the file was written correctly
data, err := os.ReadFile(outputPath) //nolint:gosec // test reads file we just wrote
require.NoError(t, err)
assert.Contains(t, string(data), `"npm install"`)
assert.Contains(t, string(data), `"curl"`)
assert.Contains(t, string(data), `"./.env"`)
}
func TestMarshalConfigJSON(t *testing.T) {
t.Run("omits empty arrays", func(t *testing.T) {
cfg := &config.Config{}
cfg.Command.Allow = []string{"npm install"}
// Leave all other arrays empty
data, err := MarshalConfigJSON(cfg)
require.NoError(t, err)
output := string(data)
assert.Contains(t, output, `"npm install"`)
assert.NotContains(t, output, `"allowedDomains"`)
assert.NotContains(t, output, `"deniedDomains"`)
assert.NotContains(t, output, `"denyRead"`)
assert.NotContains(t, output, `"allowWrite"`)
assert.NotContains(t, output, `"denyWrite"`)
assert.NotContains(t, output, `"network"`) // entire network section should be omitted
})
t.Run("includes extends field", func(t *testing.T) {
cfg := &config.Config{}
cfg.Extends = "code"
cfg.Command.Allow = []string{"npm install"}
data, err := MarshalConfigJSON(cfg)
require.NoError(t, err)
output := string(data)
assert.Contains(t, output, `"extends": "code"`)
})
t.Run("includes non-empty arrays", func(t *testing.T) {
cfg := &config.Config{}
cfg.Network.AllowedDomains = []string{"example.com"}
cfg.Filesystem.DenyRead = []string{".env"}
cfg.Command.Deny = []string{"sudo"}
data, err := MarshalConfigJSON(cfg)
require.NoError(t, err)
output := string(data)
assert.Contains(t, output, `"example.com"`)
assert.Contains(t, output, `".env"`)
assert.Contains(t, output, `"sudo"`)
})
}
func TestFormatConfigWithComment(t *testing.T) {
t.Run("adds comment when extends is set", func(t *testing.T) {
cfg := &config.Config{}
cfg.Extends = "code"
cfg.Command.Allow = []string{"npm install"}
output, err := FormatConfigWithComment(cfg)
require.NoError(t, err)
assert.Contains(t, output, `// This config extends "code".`)
assert.Contains(t, output, `// Network, filesystem, and command rules from "code" are inherited.`)
assert.Contains(t, output, `"npm install"`)
})
t.Run("no comment when extends is empty", func(t *testing.T) {
cfg := &config.Config{}
cfg.Command.Allow = []string{"npm install"}
output, err := FormatConfigWithComment(cfg)
require.NoError(t, err)
assert.NotContains(t, output, "//")
assert.Contains(t, output, `"npm install"`)
})
}
func TestIsGlobalToolRule(t *testing.T) {
tests := []struct {
rule string
expected bool
}{
{"Read", true},
{"Write", true},
{"Grep", true},
{"LS", true},
{"Bash", true},
{"Read(./.env)", false},
{"Write(./dist/**)", false},
{"Bash(npm install)", false},
{"Bash(curl:*)", false},
}
for _, tt := range tests {
t.Run(tt.rule, func(t *testing.T) {
assert.Equal(t, tt.expected, isGlobalToolRule(tt.rule))
})
}
}
func TestAppendUnique(t *testing.T) {
tests := []struct {
name string
slice []string
value string
expected []string
}{
{
name: "append to empty",
slice: []string{},
value: "a",
expected: []string{"a"},
},
{
name: "append new value",
slice: []string{"a", "b"},
value: "c",
expected: []string{"a", "b", "c"},
},
{
name: "skip duplicate",
slice: []string{"a", "b"},
value: "a",
expected: []string{"a", "b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := appendUnique(tt.slice, tt.value)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -1,331 +0,0 @@
// Package proxy provides HTTP and SOCKS5 proxy servers with domain filtering.
package proxy
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"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
monitor bool
mu sync.RWMutex
running bool
}
// NewHTTPProxy creates a new HTTP proxy with the given filter.
// If monitor is true, only blocked requests are logged.
// If debug is true, all requests and filter rules are logged.
func NewHTTPProxy(filter FilterFunc, debug, monitor bool) *HTTPProxy {
return &HTTPProxy{
filter: filter,
debug: debug,
monitor: monitor,
}
}
// 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),
ReadHeaderTimeout: 10 * time.Second,
}
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) {
start := time.Now()
host, portStr, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
portStr = "443"
}
port := 443
if portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
}
// Check if allowed
if !p.filter(host, port) {
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 403, "BLOCKED", time.Since(start))
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
return
}
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 200, "ALLOWED", time.Since(start))
// 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 func() { _ = 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 func() { _ = clientConn.Close() }()
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return
}
// 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) {
start := time.Now()
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() != "" {
if p, err := strconv.Atoi(targetURL.Port()); err == nil {
port = p
}
} else if targetURL.Scheme == "https" {
port = 443
}
if !p.filter(host, port) {
p.logRequest(r.Method, r.RequestURI, host, 403, "BLOCKED", time.Since(start))
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.logRequest(r.Method, r.RequestURI, host, 502, "ERROR", time.Since(start))
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer func() { _ = 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)
p.logRequest(r.Method, r.RequestURI, host, resp.StatusCode, "ALLOWED", time.Since(start))
}
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
if p.debug {
fmt.Fprintf(os.Stderr, "[fence:http] "+format+"\n", args...)
}
}
// logRequest logs a detailed request entry.
// In monitor mode (-m), only blocked/error requests are logged.
// In debug mode (-d), all requests are logged.
func (p *HTTPProxy) logRequest(method, url, host string, status int, action string, duration time.Duration) {
isBlocked := action == "BLOCKED" || action == "ERROR"
if p.monitor && !p.debug && !isBlocked {
return
}
if !p.debug && !p.monitor {
return
}
timestamp := time.Now().Format("15:04:05")
statusIcon := "✓"
switch action {
case "BLOCKED":
statusIcon = "✗"
case "ERROR":
statusIcon = "!"
}
fmt.Fprintf(os.Stderr, "[fence:http] %s %s %-7s %d %s %s (%v)\n", timestamp, statusIcon, method, status, host, truncateURL(url, 60), duration.Round(time.Millisecond))
}
// truncateURL shortens a URL for display.
func truncateURL(url string, maxLen int) string {
if len(url) <= maxLen {
return url
}
return url[:maxLen-3] + "..."
}
// CreateDomainFilter creates a filter function from a config.
// When debug is true, logs filter rule matches to stderr.
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.Fprintf(os.Stderr, "[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.Fprintf(os.Stderr, "[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.Fprintf(os.Stderr, "[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
}
return true
}
}
if debug {
fmt.Fprintf(os.Stderr, "[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
}

View File

@@ -1,308 +0,0 @@
package proxy
import (
"net/http"
"net/url"
"testing"
"github.com/Use-Tusk/fence/internal/config"
)
func TestTruncateURL(t *testing.T) {
tests := []struct {
name string
url string
maxLen int
want string
}{
{"short url", "https://example.com", 50, "https://example.com"},
{"exact length", "https://example.com", 19, "https://example.com"},
{"needs truncation", "https://example.com/very/long/path/to/resource", 30, "https://example.com/very/lo..."},
{"empty url", "", 50, ""},
{"very short max", "https://example.com", 10, "https:/..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateURL(tt.url, tt.maxLen)
if got != tt.want {
t.Errorf("truncateURL(%q, %d) = %q, want %q", tt.url, tt.maxLen, got, tt.want)
}
})
}
}
func TestGetHostFromRequest(t *testing.T) {
tests := []struct {
name string
host string
urlStr string
wantHost string
}{
{
name: "host header only",
host: "example.com",
urlStr: "/path",
wantHost: "example.com",
},
{
name: "host header with port",
host: "example.com:8080",
urlStr: "/path",
wantHost: "example.com",
},
{
name: "full URL overrides host",
host: "other.com",
urlStr: "http://example.com/path",
wantHost: "example.com",
},
{
name: "url with port",
host: "other.com",
urlStr: "http://example.com:9000/path",
wantHost: "example.com",
},
{
name: "ipv6 host",
host: "[::1]:8080",
urlStr: "/path",
wantHost: "[::1]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsedURL, _ := url.Parse(tt.urlStr)
req := &http.Request{
Host: tt.host,
URL: parsedURL,
}
got := GetHostFromRequest(req)
if got != tt.wantHost {
t.Errorf("GetHostFromRequest() = %q, want %q", got, tt.wantHost)
}
})
}
}
func TestCreateDomainFilter(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
host string
port int
allowed bool
}{
{
name: "nil config denies all",
cfg: nil,
host: "example.com",
port: 443,
allowed: false,
},
{
name: "allowed domain",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
},
},
host: "example.com",
port: 443,
allowed: true,
},
{
name: "denied domain takes precedence",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
DeniedDomains: []string{"example.com"},
},
},
host: "example.com",
port: 443,
allowed: false,
},
{
name: "wildcard allowed",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*.example.com"},
},
},
host: "api.example.com",
port: 443,
allowed: true,
},
{
name: "wildcard denied",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*.example.com"},
DeniedDomains: []string{"*.blocked.example.com"},
},
},
host: "api.blocked.example.com",
port: 443,
allowed: false,
},
{
name: "unmatched domain denied",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
},
},
host: "other.com",
port: 443,
allowed: false,
},
{
name: "empty allowed list denies all",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
},
},
host: "example.com",
port: 443,
allowed: false,
},
{
name: "star wildcard allows all",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
},
},
host: "any-domain.example.com",
port: 443,
allowed: true,
},
{
name: "star wildcard with deny list",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
DeniedDomains: []string{"blocked.com"},
},
},
host: "blocked.com",
port: 443,
allowed: false,
},
{
name: "star wildcard allows non-denied",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
DeniedDomains: []string{"blocked.com"},
},
},
host: "allowed.com",
port: 443,
allowed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter := CreateDomainFilter(tt.cfg, false)
got := filter(tt.host, tt.port)
if got != tt.allowed {
t.Errorf("CreateDomainFilter() filter(%q, %d) = %v, want %v", tt.host, tt.port, got, tt.allowed)
}
})
}
}
func TestCreateDomainFilterCaseInsensitive(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"Example.COM"},
},
}
filter := CreateDomainFilter(cfg, false)
tests := []struct {
host string
allowed bool
}{
{"example.com", true},
{"EXAMPLE.COM", true},
{"Example.Com", true},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
got := filter(tt.host, 443)
if got != tt.allowed {
t.Errorf("filter(%q) = %v, want %v", tt.host, got, tt.allowed)
}
})
}
}
func TestNewHTTPProxy(t *testing.T) {
filter := func(host string, port int) bool { return true }
tests := []struct {
name string
debug bool
monitor bool
}{
{"default", false, false},
{"debug mode", true, false},
{"monitor mode", false, true},
{"both modes", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proxy := NewHTTPProxy(filter, tt.debug, tt.monitor)
if proxy == nil {
t.Fatal("NewHTTPProxy() returned nil")
}
if proxy.debug != tt.debug {
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
}
if proxy.monitor != tt.monitor {
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
}
})
}
}
func TestHTTPProxyStartStop(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewHTTPProxy(filter, false, false)
port, err := proxy.Start()
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if port <= 0 {
t.Errorf("Start() returned invalid port: %d", port)
}
if proxy.Port() != port {
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
}
if err := proxy.Stop(); err != nil {
t.Errorf("Stop() error = %v", err)
}
}
func TestHTTPProxyPortBeforeStart(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewHTTPProxy(filter, false, false)
if proxy.Port() != 0 {
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
}
}

View File

@@ -1,106 +0,0 @@
package proxy
import (
"context"
"fmt"
"net"
"os"
"time"
"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
monitor bool
port int
}
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
// If monitor is true, only blocked connections are logged.
// If debug is true, all connections are logged.
func NewSOCKSProxy(filter FilterFunc, debug, monitor bool) *SOCKSProxy {
return &SOCKSProxy{
filter: filter,
debug: debug,
monitor: monitor,
}
}
// fenceRuleSet implements socks5.RuleSet for domain filtering.
type fenceRuleSet struct {
filter FilterFunc
debug bool
monitor 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)
shouldLog := r.debug || (r.monitor && !allowed)
if shouldLog {
timestamp := time.Now().Format("15:04:05")
if allowed {
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✓ CONNECT %s:%d ALLOWED\n", timestamp, host, port)
} else {
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✗ CONNECT %s:%d BLOCKED\n", timestamp, 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,
monitor: p.monitor,
}),
)
p.server = server
go func() {
if err := p.server.Serve(p.listener); err != nil {
if p.debug {
fmt.Fprintf(os.Stderr, "[fence:socks] Server error: %v\n", err)
}
}
}()
if p.debug {
fmt.Fprintf(os.Stderr, "[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

@@ -1,130 +0,0 @@
package proxy
import (
"context"
"net"
"testing"
"github.com/things-go/go-socks5"
"github.com/things-go/go-socks5/statute"
)
func TestFenceRuleSetAllow(t *testing.T) {
tests := []struct {
name string
fqdn string
ip net.IP
port int
allowed bool
}{
{
name: "allow by FQDN",
fqdn: "allowed.com",
port: 443,
allowed: true,
},
{
name: "deny by FQDN",
fqdn: "blocked.com",
port: 443,
allowed: false,
},
{
name: "fallback to IP when FQDN empty",
fqdn: "",
ip: net.ParseIP("1.2.3.4"),
port: 80,
allowed: false,
},
{
name: "allow with IP fallback",
fqdn: "",
ip: net.ParseIP("127.0.0.1"),
port: 8080,
allowed: true,
},
}
filter := func(host string, port int) bool {
return host == "allowed.com" || host == "127.0.0.1"
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rs := &fenceRuleSet{filter: filter, debug: false, monitor: false}
req := &socks5.Request{
DestAddr: &statute.AddrSpec{
FQDN: tt.fqdn,
IP: tt.ip,
Port: tt.port,
},
}
_, allowed := rs.Allow(context.Background(), req)
if allowed != tt.allowed {
t.Errorf("Allow() = %v, want %v", allowed, tt.allowed)
}
})
}
}
func TestNewSOCKSProxy(t *testing.T) {
filter := func(host string, port int) bool { return true }
tests := []struct {
name string
debug bool
monitor bool
}{
{"default", false, false},
{"debug mode", true, false},
{"monitor mode", false, true},
{"both modes", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proxy := NewSOCKSProxy(filter, tt.debug, tt.monitor)
if proxy == nil {
t.Fatal("NewSOCKSProxy() returned nil")
}
if proxy.debug != tt.debug {
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
}
if proxy.monitor != tt.monitor {
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
}
})
}
}
func TestSOCKSProxyStartStop(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewSOCKSProxy(filter, false, false)
port, err := proxy.Start()
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if port <= 0 {
t.Errorf("Start() returned invalid port: %d", port)
}
if proxy.Port() != port {
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
}
if err := proxy.Stop(); err != nil {
t.Errorf("Stop() error = %v", err)
}
}
func TestSOCKSProxyPortBeforeStart(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewSOCKSProxy(filter, false, false)
if proxy.Port() != 0 {
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
}
}

View File

@@ -312,9 +312,7 @@ func skipBenchIfSandboxed(b *testing.B) {
func benchConfig(workspace string) *config.Config { func benchConfig(workspace string) *config.Config {
return &config.Config{ return &config.Config{
Network: config.NetworkConfig{ Network: config.NetworkConfig{},
AllowedDomains: []string{},
},
Filesystem: config.FilesystemConfig{ Filesystem: config.FilesystemConfig{
AllowWrite: []string{workspace}, AllowWrite: []string{workspace},
}, },

View File

View File

@@ -34,16 +34,12 @@ func skipIfLandlockNotUsable(t *testing.T) {
} }
// assertNetworkBlocked verifies that a network command was blocked. // assertNetworkBlocked verifies that a network command was blocked.
// It checks for either a non-zero exit code OR the proxy's blocked message. // With no proxy configured, --unshare-net blocks all network at the kernel level.
func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) { func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) {
t.Helper() t.Helper()
blockedMessage := "Connection blocked by network allowlist"
if result.Failed() { if result.Failed() {
return // Command failed = blocked return // Command failed = blocked
} }
if strings.Contains(result.Stdout, blockedMessage) || strings.Contains(result.Stderr, blockedMessage) {
return // Proxy blocked the request
}
t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s", t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
result.Stdout, result.Stderr) result.Stdout, result.Stderr)
} }
@@ -277,7 +273,7 @@ func TestLinux_NetworkBlocksCurl(t *testing.T) {
workspace := createTempWorkspace(t) workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace) cfg := testConfigWithWorkspace(workspace)
// No domains allowed = all network blocked // No proxy = all network blocked
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second) result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second)
@@ -344,18 +340,19 @@ func TestLinux_NetworkBlocksDevTcp(t *testing.T) {
assertBlocked(t, result) assertBlocked(t, result)
} }
// TestLinux_ProxyAllowsAllowedDomains verifies the proxy allows configured domains. // TestLinux_TransparentProxyRoutesThroughSocks verifies traffic routes through SOCKS5 proxy.
func TestLinux_ProxyAllowsAllowedDomains(t *testing.T) { // This test requires a running SOCKS5 proxy and actual network connectivity.
func TestLinux_TransparentProxyRoutesThroughSocks(t *testing.T) {
skipIfAlreadySandboxed(t) skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl") skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t) workspace := createTempWorkspace(t)
cfg := testConfigWithNetwork("httpbin.org") cfg := testConfigWithProxy("socks5://localhost:1080")
cfg.Filesystem.AllowWrite = []string{workspace} cfg.Filesystem.AllowWrite = []string{workspace}
// This test requires actual network - skip in CI if network is unavailable // This test requires actual network and a running SOCKS5 proxy
if os.Getenv("FENCE_TEST_NETWORK") != "1" { if os.Getenv("FENCE_TEST_NETWORK") != "1" {
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests") t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
} }
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second) result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)

View File

@@ -211,18 +211,18 @@ func TestMacOS_NetworkBlocksNc(t *testing.T) {
assertBlocked(t, result) assertBlocked(t, result)
} }
// TestMacOS_ProxyAllowsAllowedDomains verifies the proxy allows configured domains. // TestMacOS_ProxyAllowsTrafficViaProxy verifies the proxy allows traffic via external proxy.
func TestMacOS_ProxyAllowsAllowedDomains(t *testing.T) { func TestMacOS_ProxyAllowsTrafficViaProxy(t *testing.T) {
skipIfAlreadySandboxed(t) skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl") skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t) workspace := createTempWorkspace(t)
cfg := testConfigWithNetwork("httpbin.org") cfg := testConfigWithProxy("socks5://localhost:1080")
cfg.Filesystem.AllowWrite = []string{workspace} cfg.Filesystem.AllowWrite = []string{workspace}
// This test requires actual network - skip in CI if network is unavailable // This test requires actual network and a running SOCKS5 proxy
if os.Getenv("FENCE_TEST_NETWORK") != "1" { if os.Getenv("FENCE_TEST_NETWORK") != "1" {
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests") t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
} }
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second) result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)

View File

@@ -125,10 +125,7 @@ func assertContains(t *testing.T, haystack, needle string) {
// testConfig creates a test configuration with sensible defaults. // testConfig creates a test configuration with sensible defaults.
func testConfig() *config.Config { func testConfig() *config.Config {
return &config.Config{ return &config.Config{
Network: config.NetworkConfig{ Network: config.NetworkConfig{},
AllowedDomains: []string{},
DeniedDomains: []string{},
},
Filesystem: config.FilesystemConfig{ Filesystem: config.FilesystemConfig{
DenyRead: []string{}, DenyRead: []string{},
AllowWrite: []string{}, AllowWrite: []string{},
@@ -149,10 +146,10 @@ func testConfigWithWorkspace(workspacePath string) *config.Config {
return cfg return cfg
} }
// testConfigWithNetwork creates a config that allows specific domains. // testConfigWithProxy creates a config with a proxy URL set.
func testConfigWithNetwork(domains ...string) *config.Config { func testConfigWithProxy(proxyURL string) *config.Config {
cfg := testConfig() cfg := testConfig()
cfg.Network.AllowedDomains = domains cfg.Network.ProxyURL = proxyURL
return cfg return cfg
} }

View File

@@ -7,10 +7,10 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -18,13 +18,13 @@ import (
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
) )
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound). // ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
type LinuxBridge struct { type ProxyBridge struct {
HTTPSocketPath string SocketPath string // Unix socket path
SOCKSSocketPath string ProxyHost string // Parsed from ProxyURL
httpProcess *exec.Cmd ProxyPort string // Parsed from ProxyURL
socksProcess *exec.Cmd process *exec.Cmd
debug bool debug bool
} }
// ReverseBridge holds the socat bridge processes for inbound connections. // ReverseBridge holds the socat bridge processes for inbound connections.
@@ -49,13 +49,18 @@ type LinuxSandboxOptions struct {
Debug bool Debug bool
} }
// NewLinuxBridge creates Unix socket bridges to the proxy servers. // NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
// This allows sandboxed processes to communicate with the host's proxy (outbound). // The bridge uses socat to forward from a Unix socket to the external proxy's TCP address.
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) { func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
if _, err := exec.LookPath("socat"); err != nil { if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err) return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
} }
u, err := parseProxyURL(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
id := make([]byte, 8) id := make([]byte, 8)
if _, err := rand.Read(id); err != nil { if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err) return nil, fmt.Errorf("failed to generate socket ID: %w", err)
@@ -63,49 +68,33 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
socketID := hex.EncodeToString(id) socketID := hex.EncodeToString(id)
tmpDir := os.TempDir() tmpDir := os.TempDir()
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID)) socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-proxy-%s.sock", socketID))
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
bridge := &LinuxBridge{ bridge := &ProxyBridge{
HTTPSocketPath: httpSocketPath, SocketPath: socketPath,
SOCKSSocketPath: socksSocketPath, ProxyHost: u.Hostname(),
debug: debug, ProxyPort: u.Port(),
debug: debug,
} }
// Start HTTP bridge: Unix socket -> TCP proxy // Start bridge: Unix socket -> external SOCKS5 proxy TCP
httpArgs := []string{ socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath), fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("TCP:localhost:%d", httpProxyPort), fmt.Sprintf("TCP:%s:%s", bridge.ProxyHost, bridge.ProxyPort),
} }
bridge.httpProcess = exec.Command("socat", httpArgs...) //nolint:gosec // args constructed from trusted input bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug { if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting HTTP bridge: socat %s\n", strings.Join(httpArgs, " ")) fmt.Fprintf(os.Stderr, "[fence:linux] Starting proxy bridge: socat %s\n", strings.Join(socatArgs, " "))
} }
if err := bridge.httpProcess.Start(); err != nil { if err := bridge.process.Start(); err != nil {
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err) return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
} }
// Start SOCKS bridge: Unix socket -> TCP proxy // Wait for socket to be created, up to 5 seconds
socksArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socksSocketPath),
fmt.Sprintf("TCP:localhost:%d", socksProxyPort),
}
bridge.socksProcess = exec.Command("socat", socksArgs...) //nolint:gosec // args constructed from trusted input
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, up to 5 seconds
for range 50 { for range 50 {
httpExists := fileExists(httpSocketPath) if fileExists(socketPath) {
socksExists := fileExists(socksSocketPath)
if httpExists && socksExists {
if debug { if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges ready (HTTP: %s, SOCKS: %s)\n", httpSocketPath, socksSocketPath) fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge ready (%s)\n", socketPath)
} }
return bridge, nil return bridge, nil
} }
@@ -113,29 +102,37 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
} }
bridge.Cleanup() bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for bridge sockets to be created") return nil, fmt.Errorf("timeout waiting for proxy bridge socket to be created")
} }
// Cleanup stops the bridge processes and removes socket files. // Cleanup stops the bridge process and removes the socket file.
func (b *LinuxBridge) Cleanup() { func (b *ProxyBridge) Cleanup() {
if b.httpProcess != nil && b.httpProcess.Process != nil { if b.process != nil && b.process.Process != nil {
_ = b.httpProcess.Process.Kill() _ = b.process.Process.Kill()
_ = b.httpProcess.Wait() _ = b.process.Wait()
} }
if b.socksProcess != nil && b.socksProcess.Process != nil { _ = os.Remove(b.SocketPath)
_ = b.socksProcess.Process.Kill()
_ = b.socksProcess.Wait()
}
// Clean up socket files
_ = os.Remove(b.HTTPSocketPath)
_ = os.Remove(b.SOCKSSocketPath)
if b.debug { if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges cleaned up\n") fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge cleaned up\n")
} }
} }
// parseProxyURL parses a SOCKS5 proxy URL and returns the parsed URL.
func parseProxyURL(proxyURL string) (*url.URL, error) {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
return nil, fmt.Errorf("proxy URL must use socks5:// or socks5h:// scheme, got %s", u.Scheme)
}
if u.Hostname() == "" || u.Port() == "" {
return nil, fmt.Errorf("proxy URL must include hostname and port")
}
return u, nil
}
// NewReverseBridge creates Unix socket bridges for inbound connections. // NewReverseBridge creates Unix socket bridges for inbound connections.
// Host listens on ports, forwards to Unix sockets that go into the sandbox. // Host listens on ports, forwards to Unix sockets that go into the sandbox.
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) { func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
@@ -308,8 +305,8 @@ func getMandatoryDenyPaths(cwd string) []string {
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox. // WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
// It uses available security features (Landlock, seccomp) with graceful fallback. // It uses available security features (Landlock, seccomp) with graceful fallback.
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) { func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{ return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{
UseLandlock: true, // Enabled by default, will fall back if not available UseLandlock: true, // Enabled by default, will fall back if not available
UseSeccomp: true, // Enabled by default UseSeccomp: true, // Enabled by default
UseEBPF: true, // Enabled by default if available UseEBPF: true, // Enabled by default if available
@@ -318,7 +315,7 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
} }
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options. // WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) { func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
if _, err := exec.LookPath("bwrap"); err != nil { if _, err := exec.LookPath("bwrap"); err != nil {
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err) return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
} }
@@ -336,19 +333,6 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary()) fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary())
} }
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
// In this mode, we skip network namespace isolation so apps that don't
// respect HTTP_PROXY can make direct connections.
hasWildcardAllow := false
if cfg != nil {
hasWildcardAllow = slices.Contains(cfg.Network.AllowedDomains, "*")
}
if opts.Debug && hasWildcardAllow {
fmt.Fprintf(os.Stderr, "[fence:linux] Wildcard allowedDomains detected - allowing direct network connections\n")
fmt.Fprintf(os.Stderr, "[fence:linux] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
}
// Build bwrap args with filesystem restrictions // Build bwrap args with filesystem restrictions
bwrapArgs := []string{ bwrapArgs := []string{
"bwrap", "bwrap",
@@ -356,13 +340,11 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
"--die-with-parent", "--die-with-parent",
} }
// Only use --unshare-net if: // Always use --unshare-net when available (network namespace isolation)
// 1. The environment supports it (has CAP_NET_ADMIN) // Inside the namespace, tun2socks will provide transparent proxy access
// 2. We're NOT in wildcard mode (need direct network access) if features.CanUnshareNet {
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
if features.CanUnshareNet && !hasWildcardAllow {
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
} else if opts.Debug && !features.CanUnshareNet { } else if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n") fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
} }
@@ -603,12 +585,19 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
} }
} }
// Bind the outbound Unix sockets into the sandbox (need to be writable) // Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
if bridge != nil { if proxyBridge != nil {
bwrapArgs = append(bwrapArgs, bwrapArgs = append(bwrapArgs,
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath, "--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
) )
// Bind /dev/net/tun for TUN device creation inside the sandbox
if features.HasDevNetTun {
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev/net/tun", "/dev/net/tun")
}
// Bind the tun2socks binary into the sandbox (read-only)
if tun2socksPath != "" {
bwrapArgs = append(bwrapArgs, "--ro-bind", tun2socksPath, "/tmp/fence-tun2socks")
}
} }
// Bind reverse socket directory if needed (sockets created inside sandbox) // Bind reverse socket directory if needed (sockets created inside sandbox)
@@ -637,32 +626,48 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c") bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up socat listeners and runs the user command // Build the inner command that sets up tun2socks and runs the user command
var innerScript strings.Builder var innerScript strings.Builder
if bridge != nil { innerScript.WriteString("export FENCE_SANDBOX=1\n")
// Set up outbound socat listeners inside the sandbox
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
// Set up transparent proxy via TUN device + tun2socks
innerScript.WriteString(fmt.Sprintf(` innerScript.WriteString(fmt.Sprintf(`
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy) # Set up TUN device for transparent proxying
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 & ip tuntap add dev tun0 mode tun
HTTP_PID=$! ip addr add 198.18.0.1/15 dev tun0
ip link set dev tun0 up
ip route add default via 198.18.0.1 dev tun0
# Start SOCKS proxy listener (port 1080 -> Unix socket -> host SOCKS proxy) # Bridge: local port -> Unix socket -> host -> external SOCKS5 proxy
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 & PROXY_PORT=18321
SOCKS_PID=$! socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
BRIDGE_PID=$!
# Set proxy environment variables # Start tun2socks (transparent proxy via gvisor netstack)
export HTTP_PROXY=http://127.0.0.1:3128 /tmp/fence-tun2socks -device tun0 -proxy socks5://127.0.0.1:${PROXY_PORT} >/dev/null 2>&1 &
export HTTPS_PROXY=http://127.0.0.1:3128 TUN2SOCKS_PID=$!
export http_proxy=http://127.0.0.1:3128
export https_proxy=http://127.0.0.1:3128 `, proxyBridge.SocketPath))
export ALL_PROXY=socks5h://127.0.0.1:1080 } else if proxyBridge != nil {
export all_proxy=socks5h://127.0.0.1:1080 // Fallback: no TUN support, use env-var-based proxying
innerScript.WriteString(fmt.Sprintf(`
# Set up SOCKS5 bridge (no TUN available, env-var-based proxying)
PROXY_PORT=18321
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
BRIDGE_PID=$!
export ALL_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export all_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export HTTP_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export HTTPS_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export http_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export https_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export NO_PROXY=localhost,127.0.0.1 export NO_PROXY=localhost,127.0.0.1
export no_proxy=localhost,127.0.0.1 export no_proxy=localhost,127.0.0.1
export FENCE_SANDBOX=1
`, bridge.HTTPSocketPath, bridge.SOCKSSocketPath)) `, proxyBridge.SocketPath))
} }
// Set up reverse (inbound) socat listeners inside the sandbox // Set up reverse (inbound) socat listeners inside the sandbox
@@ -688,8 +693,8 @@ cleanup() {
} }
trap cleanup EXIT trap cleanup EXIT
# Small delay to ensure socat listeners are ready # Small delay to ensure services are ready
sleep 0.1 sleep 0.3
# Run the user command # Run the user command
`) `)
@@ -729,6 +734,11 @@ sleep 0.1
} else { } else {
featureList = append(featureList, "bwrap(pid,fs)") featureList = append(featureList, "bwrap(pid,fs)")
} }
if proxyBridge != nil && features.CanUseTransparentProxy() {
featureList = append(featureList, "tun2socks(transparent)")
} else if proxyBridge != nil {
featureList = append(featureList, "proxy(env-vars)")
}
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" { if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
featureList = append(featureList, "seccomp") featureList = append(featureList, "seccomp")
} }
@@ -818,6 +828,9 @@ func PrintLinuxFeatures() {
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel) fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI) fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI)
fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot) fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot)
fmt.Printf(" ip (iproute2): %v\n", features.HasIpCommand)
fmt.Printf(" /dev/net/tun: %v\n", features.HasDevNetTun)
fmt.Printf(" tun2socks: %v (embedded)\n", features.HasTun2Socks)
fmt.Printf("\nFeature Status:\n") fmt.Printf("\nFeature Status:\n")
if features.MinimumViable() { if features.MinimumViable() {
@@ -841,6 +854,12 @@ func PrintLinuxFeatures() {
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n") fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n")
} }
if features.CanUseTransparentProxy() {
fmt.Printf(" ✓ Transparent proxy available (tun2socks + TUN device)\n")
} else {
fmt.Printf(" ○ Transparent proxy not available (needs ip, /dev/net/tun, network namespace)\n")
}
if features.CanUseLandlock() { if features.CanUseLandlock() {
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n") fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
} else { } else {

View File

@@ -35,6 +35,11 @@ type LinuxFeatures struct {
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN // This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
CanUnshareNet bool CanUnshareNet bool
// Transparent proxy support
HasIpCommand bool // ip (iproute2) available
HasDevNetTun bool // /dev/net/tun exists
HasTun2Socks bool // tun2socks embedded binary available
// Kernel version // Kernel version
KernelMajor int KernelMajor int
KernelMinor int KernelMinor int
@@ -74,6 +79,12 @@ func (f *LinuxFeatures) detect() {
// Check if we can create network namespaces // Check if we can create network namespaces
f.detectNetworkNamespace() f.detectNetworkNamespace()
// Check transparent proxy support
f.HasIpCommand = commandExists("ip")
_, err := os.Stat("/dev/net/tun")
f.HasDevNetTun = err == nil
f.HasTun2Socks = true // embedded binary, always available
} }
func (f *LinuxFeatures) parseKernelVersion() { func (f *LinuxFeatures) parseKernelVersion() {
@@ -255,6 +266,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
return f.HasLandlock && f.LandlockABI >= 1 return f.HasLandlock && f.LandlockABI >= 1
} }
// CanUseTransparentProxy returns true if transparent proxying via tun2socks is possible.
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
return f.HasIpCommand && f.HasDevNetTun && f.CanUnshareNet
}
// MinimumViable returns true if the minimum required features are available. // MinimumViable returns true if the minimum required features are available.
func (f *LinuxFeatures) MinimumViable() bool { func (f *LinuxFeatures) MinimumViable() bool {
return f.HasBwrap && f.HasSocat return f.HasBwrap && f.HasSocat

View File

@@ -15,6 +15,9 @@ type LinuxFeatures struct {
HasCapBPF bool HasCapBPF bool
HasCapRoot bool HasCapRoot bool
CanUnshareNet bool CanUnshareNet bool
HasIpCommand bool
HasDevNetTun bool
HasTun2Socks bool
KernelMajor int KernelMajor int
KernelMinor int KernelMinor int
} }
@@ -39,6 +42,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
return false return false
} }
// CanUseTransparentProxy returns false on non-Linux platforms.
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
return false
}
// MinimumViable returns false on non-Linux platforms. // MinimumViable returns false on non-Linux platforms.
func (f *LinuxFeatures) MinimumViable() bool { func (f *LinuxFeatures) MinimumViable() bool {
return false return false

View File

@@ -8,10 +8,11 @@ import (
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
) )
// LinuxBridge is a stub for non-Linux platforms. // ProxyBridge is a stub for non-Linux platforms.
type LinuxBridge struct { type ProxyBridge struct {
HTTPSocketPath string SocketPath string
SOCKSSocketPath string ProxyHost string
ProxyPort string
} }
// ReverseBridge is a stub for non-Linux platforms. // ReverseBridge is a stub for non-Linux platforms.
@@ -29,13 +30,13 @@ type LinuxSandboxOptions struct {
Debug bool Debug bool
} }
// NewLinuxBridge returns an error on non-Linux platforms. // NewProxyBridge returns an error on non-Linux platforms.
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) { func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
return nil, fmt.Errorf("Linux bridge not available on this platform") return nil, fmt.Errorf("proxy bridge not available on this platform")
} }
// Cleanup is a no-op on non-Linux platforms. // Cleanup is a no-op on non-Linux platforms.
func (b *LinuxBridge) Cleanup() {} func (b *ProxyBridge) Cleanup() {}
// NewReverseBridge returns an error on non-Linux platforms. // NewReverseBridge returns an error on non-Linux platforms.
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) { func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
@@ -46,12 +47,12 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
func (b *ReverseBridge) Cleanup() {} func (b *ReverseBridge) Cleanup() {}
// WrapCommandLinux returns an error on non-Linux platforms. // WrapCommandLinux returns an error on non-Linux platforms.
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) { func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform") return "", fmt.Errorf("Linux sandbox not available on this platform")
} }
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms. // WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) { func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform") return "", fmt.Errorf("Linux sandbox not available on this platform")
} }

View File

@@ -6,155 +6,34 @@ import (
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
) )
// TestLinux_WildcardAllowedDomainsSkipsUnshareNet verifies that when allowedDomains // TestLinux_NoProxyBlocksNetwork verifies that when no ProxyURL is set,
// contains "*", the Linux sandbox does NOT use --unshare-net, allowing direct // the Linux sandbox uses --unshare-net to block all network access.
// network connections for applications that don't respect HTTP_PROXY. func TestLinux_NoProxyBlocksNetwork(t *testing.T) {
func TestLinux_WildcardAllowedDomainsSkipsUnshareNet(t *testing.T) { cfg := &config.Config{
tests := []struct { Network: config.NetworkConfig{},
name string Filesystem: config.FilesystemConfig{
allowedDomains []string AllowWrite: []string{"/tmp/test"},
wantUnshareNet bool
}{
{
name: "no domains - uses unshare-net",
allowedDomains: []string{},
wantUnshareNet: true,
},
{
name: "specific domain - uses unshare-net",
allowedDomains: []string{"api.openai.com"},
wantUnshareNet: true,
},
{
name: "wildcard domain - skips unshare-net",
allowedDomains: []string{"*"},
wantUnshareNet: false,
},
{
name: "wildcard with specific domains - skips unshare-net",
allowedDomains: []string{"api.openai.com", "*"},
wantUnshareNet: false,
},
{
name: "wildcard subdomain pattern - uses unshare-net",
allowedDomains: []string{"*.openai.com"},
wantUnshareNet: true,
}, },
} }
for _, tt := range tests { // With no proxy, network should be blocked
t.Run(tt.name, func(t *testing.T) { if cfg.Network.ProxyURL != "" {
cfg := &config.Config{ t.Error("expected empty ProxyURL for no-network config")
Network: config.NetworkConfig{
AllowedDomains: tt.allowedDomains,
},
Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"},
},
}
// Check the wildcard detection logic directly
hasWildcard := hasWildcardAllowedDomain(cfg)
if tt.wantUnshareNet && hasWildcard {
t.Errorf("expected hasWildcard=false for domains %v, got true", tt.allowedDomains)
}
if !tt.wantUnshareNet && !hasWildcard {
t.Errorf("expected hasWildcard=true for domains %v, got false", tt.allowedDomains)
}
})
} }
} }
// hasWildcardAllowedDomain checks if the config contains a "*" in allowedDomains. // TestLinux_ProxyURLSet verifies that a proxy URL is properly set in config.
// This replicates the logic used in both linux.go and macos.go. func TestLinux_ProxyURLSet(t *testing.T) {
func hasWildcardAllowedDomain(cfg *config.Config) bool { cfg := &config.Config{
if cfg == nil { Network: config.NetworkConfig{
return false ProxyURL: "socks5://localhost:1080",
}
for _, d := range cfg.Network.AllowedDomains {
if d == "*" {
return true
}
}
return false
}
// TestWildcardDetectionLogic tests the wildcard detection helper.
// This logic is shared between macOS and Linux sandbox implementations.
func TestWildcardDetectionLogic(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
expectWildcard bool
}{
{
name: "nil config",
cfg: nil,
expectWildcard: false,
}, },
{ Filesystem: config.FilesystemConfig{
name: "empty allowed domains", AllowWrite: []string{"/tmp/test"},
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
},
},
expectWildcard: false,
},
{
name: "specific domains only",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com", "api.openai.com"},
},
},
expectWildcard: false,
},
{
name: "exact star wildcard",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
},
},
expectWildcard: true,
},
{
name: "star wildcard among others",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com", "*", "api.openai.com"},
},
},
expectWildcard: true,
},
{
name: "prefix wildcard is not star",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*.example.com"},
},
},
expectWildcard: false,
},
{
name: "star in domain name is not wildcard",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"test*domain.com"},
},
},
expectWildcard: false,
}, },
} }
for _, tt := range tests { if cfg.Network.ProxyURL != "socks5://localhost:1080" {
t.Run(tt.name, func(t *testing.T) { t.Errorf("expected ProxyURL socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
got := hasWildcardAllowedDomain(tt.cfg)
if got != tt.expectWildcard {
t.Errorf("hasWildcardAllowedDomain() = %v, want %v", got, tt.expectWildcard)
}
})
} }
} }

View File

@@ -4,11 +4,11 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"strings" "strings"
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
@@ -29,8 +29,9 @@ func generateSessionSuffix() string {
type MacOSSandboxParams struct { type MacOSSandboxParams struct {
Command string Command string
NeedsNetworkRestriction bool NeedsNetworkRestriction bool
HTTPProxyPort int ProxyURL string // External proxy URL (for env vars)
SOCKSProxyPort int ProxyHost string // Proxy host (for sandbox profile network rules)
ProxyPort string // Proxy port (for sandbox profile network rules)
AllowUnixSockets []string AllowUnixSockets []string
AllowAllUnixSockets bool AllowAllUnixSockets bool
AllowLocalBinding bool AllowLocalBinding bool
@@ -519,18 +520,10 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
} }
} }
if params.HTTPProxyPort > 0 { // Allow outbound to the external proxy host:port
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d")) if params.ProxyHost != "" && params.ProxyPort != "" {
(allow network-inbound (local ip "localhost:%d")) profile.WriteString(fmt.Sprintf(`(allow network-outbound (remote ip "%s:%s"))
(allow network-outbound (remote ip "localhost:%d")) `, params.ProxyHost, params.ProxyPort))
`, 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") profile.WriteString("\n")
@@ -568,15 +561,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
} }
// WrapCommandMacOS wraps a command with macOS sandbox restrictions. // WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) { func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
// In this mode, we still run the proxy for apps that respect HTTP_PROXY,
// but allow direct connections for apps that don't (like cursor-agent, opencode).
// deniedDomains will only be enforced for apps that use the proxy.
hasWildcardAllow := slices.Contains(cfg.Network.AllowedDomains, "*")
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
// Build allow paths: default + configured // Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...) allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
@@ -591,20 +576,25 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
allowLocalOutbound = *cfg.Network.AllowLocalOutbound allowLocalOutbound = *cfg.Network.AllowLocalOutbound
} }
// If wildcard allow, don't restrict network at sandbox level (allow direct connections). // Parse proxy URL for network rules
// Otherwise, restrict to localhost/proxy only (strict mode). var proxyHost, proxyPort string
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0) if cfg.Network.ProxyURL != "" {
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
if debug && hasWildcardAllow { proxyHost = u.Hostname()
fmt.Fprintf(os.Stderr, "[fence:macos] Wildcard allowedDomains detected - allowing direct network connections\n") proxyPort = u.Port()
fmt.Fprintf(os.Stderr, "[fence:macos] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n") }
} }
// Restrict network unless proxy is configured to an external host
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
needsNetworkRestriction := true
params := MacOSSandboxParams{ params := MacOSSandboxParams{
Command: command, Command: command,
NeedsNetworkRestriction: needsNetworkRestriction, NeedsNetworkRestriction: needsNetworkRestriction,
HTTPProxyPort: httpPort, ProxyURL: cfg.Network.ProxyURL,
SOCKSProxyPort: socksPort, ProxyHost: proxyHost,
ProxyPort: proxyPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets, AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets, AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding, AllowLocalBinding: allowLocalBinding,
@@ -637,7 +627,7 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
return "", fmt.Errorf("shell %q not found: %w", shell, err) return "", fmt.Errorf("shell %q not found: %w", shell, err)
} }
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort) proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
// Build the command // Build the command
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command' // env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'

View File

@@ -7,44 +7,34 @@ import (
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
) )
// TestMacOS_WildcardAllowedDomainsRelaxesNetwork verifies that when allowedDomains // TestMacOS_NetworkRestrictionWithProxy verifies that when a proxy URL is set,
// contains "*", the macOS sandbox profile allows direct network connections. // the macOS sandbox profile allows outbound to the proxy host:port.
func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) { func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
allowedDomains []string proxyURL string
wantNetworkRestricted bool wantProxy bool
wantAllowNetworkOutbound bool proxyHost string
proxyPort string
}{ }{
{ {
name: "no domains - network restricted", name: "no proxy - network blocked",
allowedDomains: []string{}, proxyURL: "",
wantNetworkRestricted: true, wantProxy: false,
wantAllowNetworkOutbound: false,
}, },
{ {
name: "specific domain - network restricted", name: "socks5 proxy - outbound allowed to proxy",
allowedDomains: []string{"api.openai.com"}, proxyURL: "socks5://proxy.example.com:1080",
wantNetworkRestricted: true, wantProxy: true,
wantAllowNetworkOutbound: false, proxyHost: "proxy.example.com",
proxyPort: "1080",
}, },
{ {
name: "wildcard domain - network unrestricted", name: "socks5h proxy - outbound allowed to proxy",
allowedDomains: []string{"*"}, proxyURL: "socks5h://localhost:1080",
wantNetworkRestricted: false, wantProxy: true,
wantAllowNetworkOutbound: true, proxyHost: "localhost",
}, proxyPort: "1080",
{
name: "wildcard with specific domains - network unrestricted",
allowedDomains: []string{"api.openai.com", "*"},
wantNetworkRestricted: false,
wantAllowNetworkOutbound: true,
},
{
name: "wildcard subdomain pattern - network restricted",
allowedDomains: []string{"*.openai.com"},
wantNetworkRestricted: true,
wantAllowNetworkOutbound: false,
}, },
} }
@@ -52,34 +42,33 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
Network: config.NetworkConfig{ Network: config.NetworkConfig{
AllowedDomains: tt.allowedDomains, ProxyURL: tt.proxyURL,
}, },
Filesystem: config.FilesystemConfig{ Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"}, AllowWrite: []string{"/tmp/test"},
}, },
} }
// Generate the sandbox profile parameters
params := buildMacOSParamsForTest(cfg) params := buildMacOSParamsForTest(cfg)
if params.NeedsNetworkRestriction != tt.wantNetworkRestricted { if tt.wantProxy {
t.Errorf("NeedsNetworkRestriction = %v, want %v", if params.ProxyHost != tt.proxyHost {
params.NeedsNetworkRestriction, tt.wantNetworkRestricted) t.Errorf("expected ProxyHost %q, got %q", tt.proxyHost, params.ProxyHost)
}
if params.ProxyPort != tt.proxyPort {
t.Errorf("expected ProxyPort %q, got %q", tt.proxyPort, params.ProxyPort)
}
profile := GenerateSandboxProfile(params)
expectedRule := `(allow network-outbound (remote ip "` + tt.proxyHost + ":" + tt.proxyPort + `"))`
if !strings.Contains(profile, expectedRule) {
t.Errorf("profile should contain proxy outbound rule %q", expectedRule)
}
} }
// Generate the actual profile and check its contents // Network should always be restricted (proxy or not)
profile := GenerateSandboxProfile(params) if !params.NeedsNetworkRestriction {
t.Error("NeedsNetworkRestriction should always be true")
// When network is unrestricted, profile should allow network* (all network ops)
if tt.wantAllowNetworkOutbound {
if !strings.Contains(profile, "(allow network*)") {
t.Errorf("expected unrestricted network profile to contain '(allow network*)', got:\n%s", profile)
}
} else {
// When network is restricted, profile should NOT have blanket allow
if strings.Contains(profile, "(allow network*)") {
t.Errorf("expected restricted network profile to NOT contain blanket '(allow network*)'")
}
} }
}) })
} }
@@ -88,15 +77,6 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config, // buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
// replicating the logic in WrapCommandMacOS for testing. // replicating the logic in WrapCommandMacOS for testing.
func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams { func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
hasWildcardAllow := false
for _, d := range cfg.Network.AllowedDomains {
if d == "*" {
hasWildcardAllow = true
break
}
}
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...) allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
allowLocalBinding := cfg.Network.AllowLocalBinding allowLocalBinding := cfg.Network.AllowLocalBinding
allowLocalOutbound := allowLocalBinding allowLocalOutbound := allowLocalBinding
@@ -104,13 +84,26 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
allowLocalOutbound = *cfg.Network.AllowLocalOutbound allowLocalOutbound = *cfg.Network.AllowLocalOutbound
} }
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0) var proxyHost, proxyPort string
if cfg.Network.ProxyURL != "" {
// Simple parsing for tests
parts := strings.SplitN(cfg.Network.ProxyURL, "://", 2)
if len(parts) == 2 {
hostPort := parts[1]
colonIdx := strings.LastIndex(hostPort, ":")
if colonIdx >= 0 {
proxyHost = hostPort[:colonIdx]
proxyPort = hostPort[colonIdx+1:]
}
}
}
return MacOSSandboxParams{ return MacOSSandboxParams{
Command: "echo test", Command: "echo test",
NeedsNetworkRestriction: needsNetworkRestriction, NeedsNetworkRestriction: true,
HTTPProxyPort: 8080, ProxyURL: cfg.Network.ProxyURL,
SOCKSProxyPort: 1080, ProxyHost: proxyHost,
ProxyPort: proxyPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets, AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets, AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding, AllowLocalBinding: allowLocalBinding,
@@ -158,8 +151,6 @@ func TestMacOS_ProfileNetworkSection(t *testing.T) {
params := MacOSSandboxParams{ params := MacOSSandboxParams{
Command: "echo test", Command: "echo test",
NeedsNetworkRestriction: tt.restricted, NeedsNetworkRestriction: tt.restricted,
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
} }
profile := GenerateSandboxProfile(params) profile := GenerateSandboxProfile(params)
@@ -195,8 +186,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
defaultDenyRead: false, defaultDenyRead: false,
allowRead: nil, allowRead: nil,
wantContainsBlanketAllow: true, wantContainsBlanketAllow: true,
wantContainsMetadataAllow: false, // No separate metadata allow needed wantContainsMetadataAllow: false,
wantContainsSystemAllows: false, // No need for explicit system allows wantContainsSystemAllows: false,
wantContainsUserAllowRead: false, wantContainsUserAllowRead: false,
}, },
{ {
@@ -204,8 +195,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
defaultDenyRead: true, defaultDenyRead: true,
allowRead: nil, allowRead: nil,
wantContainsBlanketAllow: false, wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true, // Should have file-read-metadata for traversal wantContainsMetadataAllow: true,
wantContainsSystemAllows: true, // Should have explicit system path allows wantContainsSystemAllows: true,
wantContainsUserAllowRead: false, wantContainsUserAllowRead: false,
}, },
{ {
@@ -223,35 +214,28 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
params := MacOSSandboxParams{ params := MacOSSandboxParams{
Command: "echo test", Command: "echo test",
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
DefaultDenyRead: tt.defaultDenyRead, DefaultDenyRead: tt.defaultDenyRead,
ReadAllowPaths: tt.allowRead, ReadAllowPaths: tt.allowRead,
} }
profile := GenerateSandboxProfile(params) profile := GenerateSandboxProfile(params)
// Check for blanket "(allow file-read*)" without path restrictions
// This appears at the start of read rules section in default mode
hasBlanketAllow := strings.Contains(profile, "(allow file-read*)\n") hasBlanketAllow := strings.Contains(profile, "(allow file-read*)\n")
if hasBlanketAllow != tt.wantContainsBlanketAllow { if hasBlanketAllow != tt.wantContainsBlanketAllow {
t.Errorf("blanket file-read allow = %v, want %v", hasBlanketAllow, tt.wantContainsBlanketAllow) t.Errorf("blanket file-read allow = %v, want %v", hasBlanketAllow, tt.wantContainsBlanketAllow)
} }
// Check for file-read-metadata allow (for directory traversal in defaultDenyRead mode)
hasMetadataAllow := strings.Contains(profile, "(allow file-read-metadata)") hasMetadataAllow := strings.Contains(profile, "(allow file-read-metadata)")
if hasMetadataAllow != tt.wantContainsMetadataAllow { if hasMetadataAllow != tt.wantContainsMetadataAllow {
t.Errorf("file-read-metadata allow = %v, want %v", hasMetadataAllow, tt.wantContainsMetadataAllow) t.Errorf("file-read-metadata allow = %v, want %v", hasMetadataAllow, tt.wantContainsMetadataAllow)
} }
// Check for system path allows (e.g., /usr, /bin) - should use file-read-data in strict mode
hasSystemAllows := strings.Contains(profile, `(subpath "/usr")`) || hasSystemAllows := strings.Contains(profile, `(subpath "/usr")`) ||
strings.Contains(profile, `(subpath "/bin")`) strings.Contains(profile, `(subpath "/bin")`)
if hasSystemAllows != tt.wantContainsSystemAllows { if hasSystemAllows != tt.wantContainsSystemAllows {
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile) t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
} }
// Check for user-specified allowRead paths
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 { if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
hasUserAllow := strings.Contains(profile, tt.allowRead[0]) hasUserAllow := strings.Contains(profile, tt.allowRead[0])
if !hasUserAllow { if !hasUserAllow {

View File

@@ -6,18 +6,14 @@ import (
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/platform" "github.com/Use-Tusk/fence/internal/platform"
"github.com/Use-Tusk/fence/internal/proxy"
) )
// Manager handles sandbox initialization and command wrapping. // Manager handles sandbox initialization and command wrapping.
type Manager struct { type Manager struct {
config *config.Config config *config.Config
httpProxy *proxy.HTTPProxy proxyBridge *ProxyBridge
socksProxy *proxy.SOCKSProxy
linuxBridge *LinuxBridge
reverseBridge *ReverseBridge reverseBridge *ReverseBridge
httpPort int tun2socksPath string // path to extracted tun2socks binary on host
socksPort int
exposedPorts []int exposedPorts []int
debug bool debug bool
monitor bool monitor bool
@@ -38,7 +34,7 @@ func (m *Manager) SetExposedPorts(ports []int) {
m.exposedPorts = ports m.exposedPorts = ports
} }
// Initialize sets up the sandbox infrastructure (proxies, etc.). // Initialize sets up the sandbox infrastructure.
func (m *Manager) Initialize() error { func (m *Manager) Initialize() error {
if m.initialized { if m.initialized {
return nil return nil
@@ -48,32 +44,27 @@ func (m *Manager) Initialize() error {
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect()) return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
} }
filter := proxy.CreateDomainFilter(m.config, m.debug) // On Linux, set up proxy bridge and tun2socks if proxy is configured
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug, m.monitor)
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, m.monitor)
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 { if platform.Detect() == platform.Linux {
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug) if m.config.Network.ProxyURL != "" {
if err != nil { // Extract embedded tun2socks binary
_ = m.httpProxy.Stop() tun2socksPath, err := extractTun2Socks()
_ = m.socksProxy.Stop() if err != nil {
return fmt.Errorf("failed to initialize Linux bridge: %w", err) m.logDebug("Failed to extract tun2socks: %v (will fall back to env-var proxying)", err)
} else {
m.tun2socksPath = tun2socksPath
}
// Create proxy bridge (socat: Unix socket -> external SOCKS5 proxy)
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
if err != nil {
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
}
m.proxyBridge = bridge
} }
m.linuxBridge = bridge
// Set up reverse bridge for exposed ports (inbound connections) // Set up reverse bridge for exposed ports (inbound connections)
// Only needed when network namespace is available - otherwise they share the network // Only needed when network namespace is available - otherwise they share the network
@@ -81,9 +72,12 @@ func (m *Manager) Initialize() error {
if len(m.exposedPorts) > 0 && features.CanUnshareNet { if len(m.exposedPorts) > 0 && features.CanUnshareNet {
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug) reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
if err != nil { if err != nil {
m.linuxBridge.Cleanup() if m.proxyBridge != nil {
_ = m.httpProxy.Stop() m.proxyBridge.Cleanup()
_ = m.socksProxy.Stop() }
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize reverse bridge: %w", err) return fmt.Errorf("failed to initialize reverse bridge: %w", err)
} }
m.reverseBridge = reverseBridge m.reverseBridge = reverseBridge
@@ -93,7 +87,11 @@ func (m *Manager) Initialize() error {
} }
m.initialized = true m.initialized = true
m.logDebug("Sandbox manager initialized (HTTP proxy: %d, SOCKS proxy: %d)", m.httpPort, m.socksPort) if m.config.Network.ProxyURL != "" {
m.logDebug("Sandbox manager initialized (proxy: %s)", m.config.Network.ProxyURL)
} else {
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
}
return nil return nil
} }
@@ -114,9 +112,9 @@ func (m *Manager) WrapCommand(command string) (string, error) {
plat := platform.Detect() plat := platform.Detect()
switch plat { switch plat {
case platform.MacOS: case platform.MacOS:
return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug) return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
case platform.Linux: case platform.Linux:
return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug) return WrapCommandLinux(m.config, command, m.proxyBridge, m.reverseBridge, m.tun2socksPath, m.debug)
default: default:
return "", fmt.Errorf("unsupported platform: %s", plat) return "", fmt.Errorf("unsupported platform: %s", plat)
} }
@@ -127,14 +125,11 @@ func (m *Manager) Cleanup() {
if m.reverseBridge != nil { if m.reverseBridge != nil {
m.reverseBridge.Cleanup() m.reverseBridge.Cleanup()
} }
if m.linuxBridge != nil { if m.proxyBridge != nil {
m.linuxBridge.Cleanup() m.proxyBridge.Cleanup()
} }
if m.httpProxy != nil { if m.tun2socksPath != "" {
_ = m.httpProxy.Stop() os.Remove(m.tun2socksPath)
}
if m.socksProxy != nil {
_ = m.socksProxy.Stop()
} }
m.logDebug("Sandbox manager cleaned up") m.logDebug("Sandbox manager cleaned up")
} }
@@ -144,13 +139,3 @@ func (m *Manager) logDebug(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "[fence] "+format+"\n", args...) 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
}

View File

@@ -0,0 +1,53 @@
//go:build linux
package sandbox
import (
"embed"
"fmt"
"io/fs"
"os"
"runtime"
)
//go:embed bin/tun2socks-linux-*
var tun2socksFS embed.FS
// extractTun2Socks writes the embedded tun2socks binary to a temp file and returns its path.
// The caller is responsible for removing the file when done.
func extractTun2Socks() (string, error) {
var arch string
switch runtime.GOARCH {
case "amd64":
arch = "amd64"
case "arm64":
arch = "arm64"
default:
return "", fmt.Errorf("tun2socks: unsupported architecture %s", runtime.GOARCH)
}
name := fmt.Sprintf("bin/tun2socks-linux-%s", arch)
data, err := fs.ReadFile(tun2socksFS, name)
if err != nil {
return "", fmt.Errorf("tun2socks: embedded binary not found for %s: %w", arch, err)
}
tmpFile, err := os.CreateTemp("", "fence-tun2socks-*")
if err != nil {
return "", fmt.Errorf("tun2socks: failed to create temp file: %w", err)
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return "", fmt.Errorf("tun2socks: failed to write binary: %w", err)
}
tmpFile.Close()
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("tun2socks: failed to make executable: %w", err)
}
return tmpFile.Name(), nil
}

View File

@@ -0,0 +1,10 @@
//go:build !linux
package sandbox
import "fmt"
// extractTun2Socks is not available on non-Linux platforms.
func extractTun2Socks() (string, error) {
return "", fmt.Errorf("tun2socks is only available on Linux")
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/base64" "encoding/base64"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
@@ -49,13 +48,14 @@ func NormalizePath(pathPattern string) string {
} }
// GenerateProxyEnvVars creates environment variables for proxy configuration. // GenerateProxyEnvVars creates environment variables for proxy configuration.
func GenerateProxyEnvVars(httpPort, socksPort int) []string { // Used on macOS where transparent proxying is not available.
func GenerateProxyEnvVars(proxyURL string) []string {
envVars := []string{ envVars := []string{
"FENCE_SANDBOX=1", "FENCE_SANDBOX=1",
"TMPDIR=/tmp/fence", "TMPDIR=/tmp/fence",
} }
if httpPort == 0 && socksPort == 0 { if proxyURL == "" {
return envVars return envVars
} }
@@ -75,32 +75,14 @@ func GenerateProxyEnvVars(httpPort, socksPort int) []string {
envVars = append(envVars, envVars = append(envVars,
"NO_PROXY="+noProxy, "NO_PROXY="+noProxy,
"no_proxy="+noProxy, "no_proxy="+noProxy,
"ALL_PROXY="+proxyURL,
"all_proxy="+proxyURL,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
) )
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 return envVars
} }
@@ -121,6 +103,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
return string(data), nil return string(data), nil
} }
func itoa(n int) string {
return strconv.Itoa(n)
}

View File

@@ -125,16 +125,14 @@ func TestNormalizePath(t *testing.T) {
func TestGenerateProxyEnvVars(t *testing.T) { func TestGenerateProxyEnvVars(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
httpPort int proxyURL string
socksPort int wantEnvs []string
wantEnvs []string dontWant []string
dontWant []string
}{ }{
{ {
name: "no ports", name: "no proxy",
httpPort: 0, proxyURL: "",
socksPort: 0,
wantEnvs: []string{ wantEnvs: []string{
"FENCE_SANDBOX=1", "FENCE_SANDBOX=1",
"TMPDIR=/tmp/fence", "TMPDIR=/tmp/fence",
@@ -146,56 +144,34 @@ func TestGenerateProxyEnvVars(t *testing.T) {
}, },
}, },
{ {
name: "http port only", name: "socks5 proxy",
httpPort: 8080, proxyURL: "socks5://localhost:1080",
socksPort: 0,
wantEnvs: []string{ wantEnvs: []string{
"FENCE_SANDBOX=1", "FENCE_SANDBOX=1",
"HTTP_PROXY=http://localhost:8080", "ALL_PROXY=socks5://localhost:1080",
"HTTPS_PROXY=http://localhost:8080", "all_proxy=socks5://localhost:1080",
"http_proxy=http://localhost:8080", "HTTP_PROXY=socks5://localhost:1080",
"https_proxy=http://localhost:8080", "HTTPS_PROXY=socks5://localhost:1080",
"http_proxy=socks5://localhost:1080",
"https_proxy=socks5://localhost:1080",
"NO_PROXY=", "NO_PROXY=",
"no_proxy=", "no_proxy=",
}, },
dontWant: []string{
"ALL_PROXY=",
"all_proxy=",
},
}, },
{ {
name: "socks port only", name: "socks5h proxy",
httpPort: 0, proxyURL: "socks5h://proxy.example.com:1080",
socksPort: 1080,
wantEnvs: []string{ wantEnvs: []string{
"FENCE_SANDBOX=1", "FENCE_SANDBOX=1",
"ALL_PROXY=socks5h://localhost:1080", "ALL_PROXY=socks5h://proxy.example.com:1080",
"all_proxy=socks5h://localhost:1080", "HTTP_PROXY=socks5h://proxy.example.com:1080",
"FTP_PROXY=socks5h://localhost:1080",
"GIT_SSH_COMMAND=",
},
dontWant: []string{
"HTTP_PROXY=",
"HTTPS_PROXY=",
},
},
{
name: "both ports",
httpPort: 8080,
socksPort: 1080,
wantEnvs: []string{
"FENCE_SANDBOX=1",
"HTTP_PROXY=http://localhost:8080",
"HTTPS_PROXY=http://localhost:8080",
"ALL_PROXY=socks5h://localhost:1080",
"GIT_SSH_COMMAND=",
}, },
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := GenerateProxyEnvVars(tt.httpPort, tt.socksPort) got := GenerateProxyEnvVars(tt.proxyURL)
// Check expected env vars are present // Check expected env vars are present
for _, want := range tt.wantEnvs { for _, want := range tt.wantEnvs {
@@ -207,7 +183,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
} }
} }
if !found { if !found {
t.Errorf("GenerateProxyEnvVars(%d, %d) missing %q", tt.httpPort, tt.socksPort, want) t.Errorf("GenerateProxyEnvVars(%q) missing %q", tt.proxyURL, want)
} }
} }
@@ -215,7 +191,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
for _, dontWant := range tt.dontWant { for _, dontWant := range tt.dontWant {
for _, env := range got { for _, env := range got {
if strings.HasPrefix(env, dontWant) { if strings.HasPrefix(env, dontWant) {
t.Errorf("GenerateProxyEnvVars(%d, %d) should not contain %q, got %q", tt.httpPort, tt.socksPort, dontWant, env) t.Errorf("GenerateProxyEnvVars(%q) should not contain %q, got %q", tt.proxyURL, dontWant, env)
} }
} }
} }

View File

@@ -1,8 +0,0 @@
{
"extends": "code",
"network": {
// Allow all domains directly (for apps that ignore HTTP_PROXY)
// The "*" wildcard bypasses proxy-based domain filtering
"allowedDomains": ["*"]
}
}

View File

@@ -1,29 +0,0 @@
{
"extends": "code",
"filesystem": {
// Deny reads by default, only system paths and allowRead are accessible
"defaultDenyRead": true,
"allowRead": [
// Current working directory
".",
// macOS preferences (needed by many apps)
"~/Library/Preferences",
// AI coding tool configs (need to read their own settings)
"~/.claude",
"~/.claude.json",
"~/.codex",
"~/.cursor",
"~/.opencode",
"~/.gemini",
"~/.factory",
// XDG config directory
"~/.config",
// Cache directories (some tools read from cache)
"~/.cache"
]
}
}

View File

@@ -1,206 +0,0 @@
{
"allowPty": true,
"network": {
"allowLocalBinding": true,
"allowLocalOutbound": true,
"allowedDomains": [
// LLM API providers
"api.openai.com",
"*.anthropic.com",
"api.githubcopilot.com",
"generativelanguage.googleapis.com",
"api.mistral.ai",
"api.cohere.ai",
"api.together.xyz",
"openrouter.ai",
// OpenCode
"opencode.ai",
"api.opencode.ai",
// Factory CLI (droid)
"*.factory.ai",
"api.workos.com",
// Cursor API
"*.cursor.sh",
// Git hosting
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
"gitlab.com",
// Package registries
"registry.npmjs.org",
"*.npmjs.org",
"registry.yarnpkg.com",
"pypi.org",
"files.pythonhosted.org",
"crates.io",
"static.crates.io",
"index.crates.io",
"proxy.golang.org",
"sum.golang.org",
// Model registry
"models.dev"
],
"deniedDomains": [
// Cloud metadata APIs (prevent credential theft)
"169.254.169.254",
"metadata.google.internal",
"instance-data.ec2.internal",
// Telemetry (optional, can be removed if needed)
"statsig.anthropic.com",
"*.sentry.io"
]
},
"filesystem": {
"allowWrite": [
".",
// Temp files
"/tmp",
// Local cache, needed by tools like `uv`
"~/.cache/**",
// Claude Code
"~/.claude*",
"~/.claude/**",
// Codex
"~/.codex/**",
// Cursor
"~/.cursor/**",
// OpenCode
"~/.opencode/**",
"~/.local/state/**",
// Gemini CLI
"~/.gemini/**",
// Factory CLI (droid)
"~/.factory/**",
// Package manager caches
"~/.npm/_cacache",
"~/.cache",
"~/.bun/**",
// Cargo cache (Rust, used by Codex)
"~/.cargo/registry/**",
"~/.cargo/git/**",
"~/.cargo/.package-cache",
// Shell completion cache
"~/.zcompdump*",
// XDG directories for app configs/data
"~/.local/share/**",
"~/.config/**"
],
"denyWrite": [
// Protect environment files with secrets
".env",
".env.*",
"**/.env",
"**/.env.*",
// Protect key/certificate files
"*.key",
"*.pem",
"*.p12",
"*.pfx",
"**/*.key",
"**/*.pem",
"**/*.p12",
"**/*.pfx"
],
"denyRead": [
// SSH private keys and config
"~/.ssh/id_*",
"~/.ssh/config",
"~/.ssh/*.pem",
// GPG keys
"~/.gnupg/**",
// Cloud provider credentials
"~/.aws/**",
"~/.config/gcloud/**",
"~/.kube/**",
// Docker config (may contain registry auth)
"~/.docker/**",
// GitHub CLI auth
"~/.config/gh/**",
// Package manager auth tokens
"~/.pypirc",
"~/.netrc",
"~/.git-credentials",
"~/.cargo/credentials",
"~/.cargo/credentials.toml"
]
},
"command": {
"useDefaults": true,
"deny": [
// Git commands that modify remote state
"git push",
"git reset",
"git clean",
"git checkout --",
"git rebase",
"git merge",
// Package publishing commands
"npm publish",
"pnpm publish",
"yarn publish",
"cargo publish",
"twine upload",
"gem push",
// Privilege escalation
"sudo",
// GitHub CLI commands that modify remote state
"gh pr create",
"gh pr merge",
"gh pr close",
"gh pr reopen",
"gh pr review",
"gh pr comment",
"gh release create",
"gh release delete",
"gh repo create",
"gh repo fork",
"gh repo delete",
"gh issue create",
"gh issue close",
"gh issue comment",
"gh gist create",
"gh workflow run",
"gh api",
"gh auth login",
"gh secret set",
"gh secret delete",
"gh variable set",
"gh variable delete"
]
}
}

View File

@@ -1,119 +0,0 @@
{
"allowPty": true,
"network": {
"allowLocalBinding": true,
"allowLocalOutbound": true,
"allowedDomains": ["*"],
// Block common analytics, telemetry, and error reporting services
"deniedDomains": [
// Error reporting
"*.sentry.io",
"*.ingest.sentry.io",
"sentry.io",
// Product analytics
"*.posthog.com",
"app.posthog.com",
"us.posthog.com",
"eu.posthog.com",
// Feature flags / experimentation
"*.statsig.com",
"statsig.com",
"statsig.anthropic.com",
// Customer data platforms
"*.segment.io",
"*.segment.com",
"api.segment.io",
"cdn.segment.com",
// Analytics
"*.amplitude.com",
"api.amplitude.com",
"api2.amplitude.com",
"*.mixpanel.com",
"api.mixpanel.com",
"*.heap.io",
"*.heapanalytics.com",
// Session recording
"*.fullstory.com",
"*.hotjar.com",
"*.hotjar.io",
"*.logrocket.io",
"*.logrocket.com",
// Error tracking
"*.bugsnag.com",
"notify.bugsnag.com",
"*.rollbar.com",
"api.rollbar.com",
// APM / Monitoring
"*.datadog.com",
"*.datadoghq.com",
"*.newrelic.com",
"*.nr-data.net",
// Feature flags
"*.launchdarkly.com",
"*.split.io",
// Product analytics / user engagement
"*.pendo.io",
"*.intercom.io",
"*.intercom.com",
// Mobile attribution
"*.appsflyer.com",
"*.adjust.com",
"*.branch.io",
// Crash reporting
"crashlytics.com",
"*.crashlytics.com",
"firebase-settings.crashlytics.com"
]
},
"filesystem": {
"allowWrite": [
".",
"/tmp",
// Local cache, needed by tools like `uv`
"~/.cache/**",
// Claude Code state/config
"~/.claude*",
"~/.claude/**",
// Codex state/config
"~/.codex/**",
// Package manager caches
"~/.npm/_cacache",
"~/.cache",
"~/.bun/**",
// Cargo cache (Rust, used by Codex)
"~/.cargo/registry/**",
"~/.cargo/git/**",
"~/.cargo/.package-cache",
// Shell completion cache
"~/.zcompdump*",
// XDG directories for app configs/data
"~/.local/share/**",
"~/.config/**",
// OpenCode state
"~/.opencode/**"
]
}
}

View File

@@ -1,19 +0,0 @@
{
"network": {
"allowedDomains": []
},
"filesystem": {
"allowWrite": ["."],
"denyWrite": [".git"]
},
"command": {
"deny": [
"git push",
"git reset",
"git clean",
"git checkout --",
"git rebase",
"git merge"
]
}
}

View File

@@ -1,9 +0,0 @@
{
"network": {
"allowLocalBinding": true,
"allowLocalOutbound": true
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}

View File

@@ -1,253 +0,0 @@
// Package templates provides embedded configuration templates for fence.
package templates
import (
"embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/Use-Tusk/fence/internal/config"
"github.com/tidwall/jsonc"
)
// maxExtendsDepth limits inheritance chain depth to prevent infinite loops.
const maxExtendsDepth = 10
// isPath returns true if the extends value looks like a file path rather than a template name.
// A value is considered a path if it contains a path separator or starts with ".".
func isPath(s string) bool {
return strings.ContainsAny(s, "/\\") || strings.HasPrefix(s, ".")
}
//go:embed *.json
var templatesFS embed.FS
// Template represents a named configuration template.
type Template struct {
Name string
Description string
}
// AvailableTemplates lists all embedded templates with descriptions.
var templateDescriptions = map[string]string{
"default-deny": "No network allowlist; no write access (most restrictive)",
"disable-telemetry": "Block analytics/error reporting (Sentry, Posthog, Statsig, etc.)",
"workspace-write": "Allow writes in the current directory",
"npm-install": "Allow npm registry; allow writes to workspace/node_modules/tmp",
"pip-install": "Allow PyPI; allow writes to workspace/tmp",
"local-dev-server": "Allow binding and localhost outbound; allow writes to workspace/tmp",
"git-readonly": "Blocks destructive commands like git push, rm -rf, etc.",
"code": "Production-ready config for AI coding agents (Claude Code, Codex, Copilot, etc.)",
"code-relaxed": "Like 'code' but allows direct network for apps that ignore HTTP_PROXY (cursor-agent, opencode)",
"code-strict": "Like 'code' but denies reads by default; only allows reading the current project directory and essential system paths",
}
// List returns all available template names sorted alphabetically.
func List() []Template {
entries, err := templatesFS.ReadDir(".")
if err != nil {
return nil
}
var templates []Template
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".json")
desc := templateDescriptions[name]
if desc == "" {
desc = "No description available"
}
templates = append(templates, Template{Name: name, Description: desc})
}
sort.Slice(templates, func(i, j int) bool {
return templates[i].Name < templates[j].Name
})
return templates
}
// Load loads a template by name and returns the parsed config.
// If the template uses "extends", the inheritance chain is resolved.
func Load(name string) (*config.Config, error) {
return loadWithDepth(name, 0, nil)
}
// loadWithDepth loads a template with cycle and depth tracking.
func loadWithDepth(name string, depth int, seen map[string]bool) (*config.Config, error) {
if depth > maxExtendsDepth {
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
}
// Normalize name (remove .json if present)
name = strings.TrimSuffix(name, ".json")
// Check for cycles
if seen == nil {
seen = make(map[string]bool)
}
if seen[name] {
return nil, fmt.Errorf("circular extends detected: %q", name)
}
seen[name] = true
filename := name + ".json"
data, err := templatesFS.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("template %q not found", name)
}
var cfg config.Config
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
}
// If this template extends another, resolve the chain
if cfg.Extends != "" {
baseCfg, err := loadWithDepth(cfg.Extends, depth+1, seen)
if err != nil {
return nil, fmt.Errorf("failed to load base template %q: %w", cfg.Extends, err)
}
return config.Merge(baseCfg, &cfg), nil
}
return &cfg, nil
}
// Exists checks if a template with the given name exists.
func Exists(name string) bool {
name = strings.TrimSuffix(name, ".json")
filename := name + ".json"
_, err := templatesFS.ReadFile(filename)
return err == nil
}
// GetPath returns the embedded path for a template (for display purposes).
func GetPath(name string) string {
name = strings.TrimSuffix(name, ".json")
return filepath.Join("internal/templates", name+".json")
}
// ResolveExtends resolves the extends field in a config by loading and merging
// the base template or config file. If the config has no extends field, it is returned as-is.
// Relative paths are resolved relative to the current working directory.
// Use ResolveExtendsWithBaseDir if you need to resolve relative to a specific directory.
func ResolveExtends(cfg *config.Config) (*config.Config, error) {
return ResolveExtendsWithBaseDir(cfg, "")
}
// ResolveExtendsWithBaseDir resolves the extends field in a config.
// The baseDir is used to resolve relative paths in the extends field.
// If baseDir is empty, relative paths will be resolved relative to the current working directory.
//
// The extends field can be:
// - A template name (e.g., "code", "npm-install")
// - An absolute path (e.g., "/path/to/base.json")
// - A relative path (e.g., "./base.json", "../shared/base.json")
//
// Paths are detected by the presence of "/" or "\" or a leading ".".
func ResolveExtendsWithBaseDir(cfg *config.Config, baseDir string) (*config.Config, error) {
if cfg == nil || cfg.Extends == "" {
return cfg, nil
}
return resolveExtendsWithDepth(cfg, baseDir, 0, nil)
}
// resolveExtendsWithDepth resolves extends with cycle and depth tracking.
func resolveExtendsWithDepth(cfg *config.Config, baseDir string, depth int, seen map[string]bool) (*config.Config, error) {
if cfg == nil || cfg.Extends == "" {
return cfg, nil
}
if depth > maxExtendsDepth {
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
}
if seen == nil {
seen = make(map[string]bool)
}
var baseCfg *config.Config
var newBaseDir string
var err error
// Handle file path or template name extends
if isPath(cfg.Extends) {
baseCfg, newBaseDir, err = loadConfigFile(cfg.Extends, baseDir, seen)
} else {
baseCfg, err = loadWithDepth(cfg.Extends, depth+1, seen)
newBaseDir = ""
}
if err != nil {
return nil, err
}
// If the base config also has extends, resolve it recursively
if baseCfg.Extends != "" {
baseCfg, err = resolveExtendsWithDepth(baseCfg, newBaseDir, depth+1, seen)
if err != nil {
return nil, err
}
}
return config.Merge(baseCfg, cfg), nil
}
// loadConfigFile loads a config from a file path with cycle detection.
// Returns the loaded config, the directory of the loaded file (for resolving nested extends), and any error.
func loadConfigFile(path, baseDir string, seen map[string]bool) (*config.Config, string, error) {
var resolvedPath string
switch {
case filepath.IsAbs(path):
resolvedPath = path
case baseDir != "":
resolvedPath = filepath.Join(baseDir, path)
default:
var err error
resolvedPath, err = filepath.Abs(path)
if err != nil {
return nil, "", fmt.Errorf("failed to resolve path %q: %w", path, err)
}
}
// Clean and normalize the path for cycle detection
resolvedPath = filepath.Clean(resolvedPath)
if seen[resolvedPath] {
return nil, "", fmt.Errorf("circular extends detected: %q", path)
}
seen[resolvedPath] = true
data, err := os.ReadFile(resolvedPath) //nolint:gosec // user-provided config path - intentional
if err != nil {
if os.IsNotExist(err) {
return nil, "", fmt.Errorf("extends file not found: %q", path)
}
return nil, "", fmt.Errorf("failed to read extends file %q: %w", path, err)
}
// Handle empty file
if len(strings.TrimSpace(string(data))) == 0 {
return nil, "", fmt.Errorf("extends file is empty: %q", path)
}
var cfg config.Config
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
return nil, "", fmt.Errorf("invalid JSON in extends file %q: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return nil, "", fmt.Errorf("invalid configuration in extends file %q: %w", path, err)
}
return &cfg, filepath.Dir(resolvedPath), nil
}

View File

@@ -1,618 +0,0 @@
package templates
import (
"os"
"path/filepath"
"testing"
"github.com/Use-Tusk/fence/internal/config"
)
func TestList(t *testing.T) {
templates := List()
if len(templates) == 0 {
t.Fatal("expected at least one template")
}
// Check that code template exists
found := false
for _, tmpl := range templates {
if tmpl.Name == "code" {
found = true
if tmpl.Description == "" {
t.Error("code template should have a description")
}
break
}
}
if !found {
t.Error("code template not found")
}
}
func TestLoad(t *testing.T) {
tests := []struct {
name string
wantErr bool
}{
{"code", false},
{"disable-telemetry", false},
{"git-readonly", false},
{"local-dev-server", false},
{"nonexistent", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := Load(tt.name)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cfg == nil {
t.Error("expected config, got nil")
}
}
})
}
}
func TestLoadWithJsonExtension(t *testing.T) {
// Should work with or without .json extension
cfg1, err := Load("disable-telemetry")
if err != nil {
t.Fatalf("failed to load disable-telemetry: %v", err)
}
cfg2, err := Load("disable-telemetry.json")
if err != nil {
t.Fatalf("failed to load disable-telemetry.json: %v", err)
}
// Both should return valid configs
if cfg1 == nil || cfg2 == nil {
t.Error("expected both configs to be non-nil")
}
}
func TestExists(t *testing.T) {
if !Exists("code") {
t.Error("code template should exist")
}
if Exists("nonexistent") {
t.Error("nonexistent should not exist")
}
}
func TestCodeTemplate(t *testing.T) {
cfg, err := Load("code")
if err != nil {
t.Fatalf("failed to load code template: %v", err)
}
// Verify key settings
if !cfg.AllowPty {
t.Error("code template should have AllowPty=true")
}
if len(cfg.Network.AllowedDomains) == 0 {
t.Error("code template should have allowed domains")
}
// Check that *.anthropic.com is in allowed domains
found := false
for _, domain := range cfg.Network.AllowedDomains {
if domain == "*.anthropic.com" {
found = true
break
}
}
if !found {
t.Error("*.anthropic.com should be in allowed domains")
}
// Check that cloud metadata domains are denied
if len(cfg.Network.DeniedDomains) == 0 {
t.Error("code template should have denied domains")
}
// Check command deny list
if len(cfg.Command.Deny) == 0 {
t.Error("code template should have denied commands")
}
}
func TestCodeStrictTemplate(t *testing.T) {
cfg, err := Load("code-strict")
if err != nil {
t.Fatalf("failed to load code-strict template: %v", err)
}
// Should inherit AllowPty from code template
if !cfg.AllowPty {
t.Error("code-strict should inherit AllowPty=true from code")
}
// Should have defaultDenyRead enabled
if !cfg.Filesystem.DefaultDenyRead {
t.Error("code-strict should have DefaultDenyRead=true")
}
// Should have allowRead with current directory
if len(cfg.Filesystem.AllowRead) == 0 {
t.Error("code-strict should have allowRead paths")
}
hasCurrentDir := false
for _, path := range cfg.Filesystem.AllowRead {
if path == "." {
hasCurrentDir = true
break
}
}
if !hasCurrentDir {
t.Error("code-strict should allow reading current directory")
}
// Should inherit allowWrite from code
if len(cfg.Filesystem.AllowWrite) == 0 {
t.Error("code-strict should inherit allowWrite from code")
}
// Should inherit denyWrite from code
if len(cfg.Filesystem.DenyWrite) == 0 {
t.Error("code-strict should inherit denyWrite from code")
}
// Should inherit allowed domains from code
if len(cfg.Network.AllowedDomains) == 0 {
t.Error("code-strict should inherit allowed domains from code")
}
// Should inherit denied commands from code
if len(cfg.Command.Deny) == 0 {
t.Error("code-strict should inherit denied commands from code")
}
// Extends should be cleared after resolution
if cfg.Extends != "" {
t.Error("extends should be cleared after loading")
}
}
func TestCodeRelaxedTemplate(t *testing.T) {
cfg, err := Load("code-relaxed")
if err != nil {
t.Fatalf("failed to load code-relaxed template: %v", err)
}
// Should inherit AllowPty from code template
if !cfg.AllowPty {
t.Error("code-relaxed should inherit AllowPty=true from code")
}
// Should have wildcard in allowed domains
hasWildcard := false
for _, domain := range cfg.Network.AllowedDomains {
if domain == "*" {
hasWildcard = true
break
}
}
if !hasWildcard {
t.Error("code-relaxed should have '*' in allowed domains")
}
// Should inherit denied domains from code
if len(cfg.Network.DeniedDomains) == 0 {
t.Error("code-relaxed should inherit denied domains from code")
}
// Should inherit filesystem config from code
if len(cfg.Filesystem.AllowWrite) == 0 {
t.Error("code-relaxed should inherit allowWrite from code")
}
if len(cfg.Filesystem.DenyRead) == 0 {
t.Error("code-relaxed should inherit denyRead from code")
}
if len(cfg.Filesystem.DenyWrite) == 0 {
t.Error("code-relaxed should inherit denyWrite from code")
}
// Should inherit command config from code
if len(cfg.Command.Deny) == 0 {
t.Error("code-relaxed should inherit command deny list from code")
}
// Extends should be cleared after resolution
if cfg.Extends != "" {
t.Error("extends should be cleared after loading")
}
}
func TestResolveExtends(t *testing.T) {
t.Run("nil config", func(t *testing.T) {
result, err := ResolveExtends(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Error("expected nil result for nil input")
}
})
t.Run("no extends", func(t *testing.T) {
cfg := &config.Config{
AllowPty: true,
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
},
}
result, err := ResolveExtends(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != cfg {
t.Error("expected same config when no extends")
}
})
t.Run("extends code template", func(t *testing.T) {
cfg := &config.Config{
Extends: "code",
Network: config.NetworkConfig{
AllowedDomains: []string{"private-registry.company.com"},
},
}
result, err := ResolveExtends(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have merged config
if result.Extends != "" {
t.Error("extends should be cleared after resolution")
}
// Should have AllowPty from base template
if !result.AllowPty {
t.Error("should inherit AllowPty from code template")
}
// Should have domains from both
hasPrivateRegistry := false
hasAnthropic := false
for _, domain := range result.Network.AllowedDomains {
if domain == "private-registry.company.com" {
hasPrivateRegistry = true
}
if domain == "*.anthropic.com" {
hasAnthropic = true
}
}
if !hasPrivateRegistry {
t.Error("should have private-registry.company.com from override")
}
if !hasAnthropic {
t.Error("should have *.anthropic.com from base template")
}
})
t.Run("extends nonexistent template", func(t *testing.T) {
cfg := &config.Config{
Extends: "nonexistent-template",
}
_, err := ResolveExtends(cfg)
if err == nil {
t.Error("expected error for nonexistent template")
}
})
}
func TestExtendsChainDepth(t *testing.T) {
// This tests that the maxExtendsDepth limit is respected.
// We can't easily create a deep chain with embedded templates,
// but we can test that the code template (which has no extends)
// loads correctly.
cfg, err := Load("code")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Error("expected non-nil config")
}
}
func TestIsPath(t *testing.T) {
tests := []struct {
input string
want bool
}{
// Template names (not paths)
{"code", false},
{"npm-install", false},
{"my-template", false},
// Absolute paths
{"/path/to/config.json", true},
{"/etc/fence/base.json", true},
// Relative paths
{"./base.json", true},
{"../shared/base.json", true},
{"configs/base.json", true},
// Windows-style paths
{"C:\\path\\to\\config.json", true},
{".\\base.json", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isPath(tt.input)
if got != tt.want {
t.Errorf("isPath(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestExtendsFilePath(t *testing.T) {
// Create temp directory for test files
tmpDir := t.TempDir()
t.Run("extends absolute path", func(t *testing.T) {
// Create base config file
baseContent := `{
"network": {
"allowedDomains": ["base.example.com"]
},
"filesystem": {
"allowWrite": ["/tmp"]
}
}`
basePath := filepath.Join(tmpDir, "base.json")
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
t.Fatalf("failed to write base config: %v", err)
}
// Config that extends the base via absolute path
cfg := &config.Config{
Extends: basePath,
Network: config.NetworkConfig{
AllowedDomains: []string{"override.example.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have merged domains
if len(result.Network.AllowedDomains) != 2 {
t.Errorf("expected 2 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
// Should have filesystem from base
if len(result.Filesystem.AllowWrite) != 1 || result.Filesystem.AllowWrite[0] != "/tmp" {
t.Errorf("expected AllowWrite [/tmp], got %v", result.Filesystem.AllowWrite)
}
})
t.Run("extends relative path", func(t *testing.T) {
// Create base config in subdir
subDir := filepath.Join(tmpDir, "configs")
if err := os.MkdirAll(subDir, 0o750); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
baseContent := `{
"allowPty": true,
"network": {
"allowedDomains": ["relative-base.example.com"]
}
}`
basePath := filepath.Join(subDir, "base.json")
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
t.Fatalf("failed to write base config: %v", err)
}
// Config that extends via relative path
cfg := &config.Config{
Extends: "./configs/base.json",
Network: config.NetworkConfig{
AllowedDomains: []string{"child.example.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, tmpDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should inherit AllowPty
if !result.AllowPty {
t.Error("should inherit AllowPty from base")
}
// Should have merged domains
if len(result.Network.AllowedDomains) != 2 {
t.Errorf("expected 2 domains, got %d", len(result.Network.AllowedDomains))
}
})
t.Run("extends nonexistent file", func(t *testing.T) {
cfg := &config.Config{
Extends: "/nonexistent/path/config.json",
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for nonexistent file")
}
})
t.Run("extends invalid JSON file", func(t *testing.T) {
invalidPath := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o600); err != nil {
t.Fatalf("failed to write invalid config: %v", err)
}
cfg := &config.Config{
Extends: invalidPath,
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for invalid JSON")
}
})
t.Run("extends file with invalid config", func(t *testing.T) {
// Create config with invalid domain pattern
invalidContent := `{
"network": {
"allowedDomains": ["*.com"]
}
}`
invalidPath := filepath.Join(tmpDir, "invalid-domain.json")
if err := os.WriteFile(invalidPath, []byte(invalidContent), 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg := &config.Config{
Extends: invalidPath,
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for invalid config")
}
})
t.Run("circular extends via files", func(t *testing.T) {
// Create two files that extend each other
fileA := filepath.Join(tmpDir, "a.json")
fileB := filepath.Join(tmpDir, "b.json")
contentA := `{"extends": "` + fileB + `"}`
contentB := `{"extends": "` + fileA + `"}`
if err := os.WriteFile(fileA, []byte(contentA), 0o600); err != nil {
t.Fatalf("failed to write a.json: %v", err)
}
if err := os.WriteFile(fileB, []byte(contentB), 0o600); err != nil {
t.Fatalf("failed to write b.json: %v", err)
}
cfg := &config.Config{
Extends: fileA,
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for circular extends")
}
})
t.Run("nested extends chain", func(t *testing.T) {
// Create a chain: child -> middle -> base
baseContent := `{
"network": {
"allowedDomains": ["base.com"]
}
}`
basePath := filepath.Join(tmpDir, "chain-base.json")
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
t.Fatalf("failed to write base: %v", err)
}
middleContent := `{
"extends": "` + basePath + `",
"network": {
"allowedDomains": ["middle.com"]
}
}`
middlePath := filepath.Join(tmpDir, "chain-middle.json")
if err := os.WriteFile(middlePath, []byte(middleContent), 0o600); err != nil {
t.Fatalf("failed to write middle: %v", err)
}
cfg := &config.Config{
Extends: middlePath,
Network: config.NetworkConfig{
AllowedDomains: []string{"child.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have all three domains
if len(result.Network.AllowedDomains) != 3 {
t.Errorf("expected 3 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
})
t.Run("file extends template", func(t *testing.T) {
// Create a file that extends a built-in template
fileContent := `{
"extends": "code",
"network": {
"allowedDomains": ["extra.example.com"]
}
}`
filePath := filepath.Join(tmpDir, "extends-template.json")
if err := os.WriteFile(filePath, []byte(fileContent), 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Config that extends this file
cfg := &config.Config{
Extends: filePath,
Network: config.NetworkConfig{
AllowedDomains: []string{"top.example.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have AllowPty from code template
if !result.AllowPty {
t.Error("should inherit AllowPty from code template")
}
// Should have domains from all levels
hasAnthropic := false
hasExtra := false
hasTop := false
for _, domain := range result.Network.AllowedDomains {
switch domain {
case "*.anthropic.com":
hasAnthropic = true
case "extra.example.com":
hasExtra = true
case "top.example.com":
hasTop = true
}
}
if !hasAnthropic {
t.Error("should have *.anthropic.com from code template")
}
if !hasExtra {
t.Error("should have extra.example.com from middle file")
}
if !hasTop {
t.Error("should have top.example.com from top config")
}
})
}