Add code-relaxed template, handle wildcard network allow

This commit is contained in:
JY Tan
2025-12-29 01:39:41 -08:00
parent d8e55d9515
commit 90cd0a0a4b
10 changed files with 535 additions and 12 deletions

View File

@@ -36,17 +36,23 @@ Run:
fence --settings ./fence.json <agent-command>
```
## Real-world usage
## Popular CLI coding agents
Currently, we provide the `code.json` template. You can use it by running `fence -t code -- claude`.
We provide these template for guardrailing CLI coding agents:
However, not all coding agent CLIs work with Fence yet. We're actively investigating these issues.
- [`code`](/internal/templates/code.json) - Strict deny-by-default network filtering via proxy. Works with agents that respect `HTTP_PROXY`. Blocks cloud metadata APIs, protects secrets, restricts dangerous commands.
- [`code-relaxed`](/internal/templates/code-relaxed.json) - Allows direct network connections for agents that ignore `HTTP_PROXY`. Same filesystem/command protections as `code`, but `deniedDomains` only enforced for proxy-respecting apps.
| Agent | Works? | Notes |
You can use it like `fence -t code -- claude`.
However, not all coding agent CLIs work with Fence at the moment.
| Agent | Works with template | Notes |
|-------|--------| ----- |
| Claude Code | ✅ | Fully working with `code` template |
| Codex | ❌ | Missing unidentified sandbox permission for interactive mode |
| OpenCode | ❌ | Ignores proxy env vars; makes direct network connections |
| Claude Code | `code` | - |
| Codex | `code` | |
| Cursor Agent | `code-relaxed` | Node.js/undici doesn't respect HTTP_PROXY |
| OpenCode | - | TUI hangs. Bun runtime doesn't respect HTTP_PROXY; architectural limitation |
## Protecting your environment

View File

@@ -34,6 +34,19 @@ Example config:
| `httpProxyPort` | Fixed port for HTTP proxy (default: random available port) |
| `socksProxyPort` | Fixed port for SOCKS5 proxy (default: random available port) |
### Wildcard Domain Access
Setting `allowedDomains: ["*"]` enables **relaxed network mode**:
- Direct network connections are allowed (sandbox doesn't block outbound)
- Proxy still runs for apps that respect `HTTP_PROXY`
- `deniedDomains` is only enforced for apps using the proxy
> [!WARNING]
> **Security tradeoff**: Apps that ignore `HTTP_PROXY` will bypass `deniedDomains` filtering entirely.
Use this when you need to support apps that don't respect proxy environment variables.
## Filesystem Configuration
| Field | Description |

View File

@@ -24,5 +24,6 @@ You can also copy and customize templates from [`internal/templates/`](/internal
| Template | Description |
|----------|-------------|
| `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 |
| `git-readonly` | Blocks destructive commands like `git push`, `rm -rf`, etc. |
| `local-dev-server` | Allow binding and localhost outbound; allow writes to workspace/tmp |

View File

@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
@@ -275,6 +276,19 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
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
bwrapArgs := []string{
"bwrap",
@@ -282,11 +296,13 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
"--die-with-parent",
}
// Only use --unshare-net if the environment supports it
// Only use --unshare-net if:
// 1. The environment supports it (has CAP_NET_ADMIN)
// 2. We're NOT in wildcard mode (need direct network access)
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
if features.CanUnshareNet {
if features.CanUnshareNet && !hasWildcardAllow {
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
} else if opts.Debug {
} else if opts.Debug && !features.CanUnshareNet {
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
}

View File

@@ -0,0 +1,160 @@
package sandbox
import (
"testing"
"github.com/Use-Tusk/fence/internal/config"
)
// TestLinux_WildcardAllowedDomainsSkipsUnshareNet verifies that when allowedDomains
// contains "*", the Linux sandbox does NOT use --unshare-net, allowing direct
// network connections for applications that don't respect HTTP_PROXY.
func TestLinux_WildcardAllowedDomainsSkipsUnshareNet(t *testing.T) {
tests := []struct {
name string
allowedDomains []string
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 {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.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.
// This replicates the logic used in both linux.go and macos.go.
func hasWildcardAllowedDomain(cfg *config.Config) bool {
if cfg == nil {
return false
}
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,
},
{
name: "empty allowed domains",
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 {
t.Run(tt.name, func(t *testing.T) {
got := hasWildcardAllowedDomain(tt.cfg)
if got != tt.expectWildcard {
t.Errorf("hasWildcardAllowedDomain() = %v, want %v", got, tt.expectWildcard)
}
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/Use-Tusk/fence/internal/config"
@@ -493,6 +494,12 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
// 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
@@ -506,9 +513,18 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
}
// If wildcard allow, don't restrict network at sandbox level (allow direct connections).
// Otherwise, restrict to localhost/proxy only (strict mode).
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
if debug && hasWildcardAllow {
fmt.Fprintf(os.Stderr, "[fence:macos] Wildcard allowedDomains detected - allowing direct network connections\n")
fmt.Fprintf(os.Stderr, "[fence:macos] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
}
params := MacOSSandboxParams{
Command: command,
NeedsNetworkRestriction: needsNetwork || len(cfg.Network.AllowedDomains) == 0, // Block if no domains allowed
NeedsNetworkRestriction: needsNetworkRestriction,
HTTPProxyPort: httpPort,
SOCKSProxyPort: socksPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
@@ -541,7 +557,6 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
// Generate proxy environment variables
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
// Build the command

View File

@@ -0,0 +1,178 @@
package sandbox
import (
"strings"
"testing"
"github.com/Use-Tusk/fence/internal/config"
)
// TestMacOS_WildcardAllowedDomainsRelaxesNetwork verifies that when allowedDomains
// contains "*", the macOS sandbox profile allows direct network connections.
func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
tests := []struct {
name string
allowedDomains []string
wantNetworkRestricted bool
wantAllowNetworkOutbound bool
}{
{
name: "no domains - network restricted",
allowedDomains: []string{},
wantNetworkRestricted: true,
wantAllowNetworkOutbound: false,
},
{
name: "specific domain - network restricted",
allowedDomains: []string{"api.openai.com"},
wantNetworkRestricted: true,
wantAllowNetworkOutbound: false,
},
{
name: "wildcard domain - network unrestricted",
allowedDomains: []string{"*"},
wantNetworkRestricted: false,
wantAllowNetworkOutbound: true,
},
{
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,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{
AllowedDomains: tt.allowedDomains,
},
Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"},
},
}
// Generate the sandbox profile parameters
params := buildMacOSParamsForTest(cfg)
if params.NeedsNetworkRestriction != tt.wantNetworkRestricted {
t.Errorf("NeedsNetworkRestriction = %v, want %v",
params.NeedsNetworkRestriction, tt.wantNetworkRestricted)
}
// Generate the actual profile and check its contents
profile := GenerateSandboxProfile(params)
// 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*)'")
}
}
})
}
}
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
// replicating the logic in WrapCommandMacOS for testing.
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...)
allowLocalBinding := cfg.Network.AllowLocalBinding
allowLocalOutbound := allowLocalBinding
if cfg.Network.AllowLocalOutbound != nil {
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
}
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
return MacOSSandboxParams{
Command: "echo test",
NeedsNetworkRestriction: needsNetworkRestriction,
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,
WriteDenyPaths: cfg.Filesystem.DenyWrite,
AllowPty: cfg.AllowPty,
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
}
}
// TestMacOS_ProfileNetworkSection verifies the network section of generated profiles.
func TestMacOS_ProfileNetworkSection(t *testing.T) {
tests := []struct {
name string
restricted bool
wantContains []string
wantNotContain []string
}{
{
name: "unrestricted network allows all",
restricted: false,
wantContains: []string{
"(allow network*)", // Blanket allow all network operations
},
wantNotContain: []string{},
},
{
name: "restricted network does not allow all",
restricted: true,
wantContains: []string{
"; Network", // Should have network section
},
wantNotContain: []string{
"(allow network*)", // Should NOT have blanket allow
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
NeedsNetworkRestriction: tt.restricted,
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
}
profile := GenerateSandboxProfile(params)
for _, want := range tt.wantContains {
if !strings.Contains(profile, want) {
t.Errorf("profile should contain %q, got:\n%s", want, profile)
}
}
for _, notWant := range tt.wantNotContain {
if strings.Contains(profile, notWant) {
t.Errorf("profile should NOT contain %q", notWant)
}
}
})
}
}

View File

@@ -0,0 +1,127 @@
{
"allowPty": true,
"network": {
"allowLocalBinding": true,
"allowLocalOutbound": true,
"allowedDomains": ["*"],
"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",
// Claude Code state/config
"~/.claude*",
"~/.claude/**",
// Codex state/config
"~/.codex/**",
// Cursor state/config
"~/.cursor/**",
// 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/**"
],
"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"
]
}
}

View File

@@ -14,6 +14,9 @@
"api.together.xyz",
"openrouter.ai",
// Cursor API
"*.cursor.sh",
// Git hosting
"github.com",
"api.github.com",
@@ -63,6 +66,9 @@
// Codex state/config
"~/.codex/**",
// Cursor state/config
"~/.cursor/**",
// Package manager caches
"~/.npm/_cacache",
"~/.cache",

View File

@@ -32,6 +32,7 @@ var templateDescriptions = map[string]string{
"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)",
}
// List returns all available template names sorted alphabetically.