Add code-relaxed template, handle wildcard network allow
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
160
internal/sandbox/linux_test.go
Normal file
160
internal/sandbox/linux_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
178
internal/sandbox/macos_test.go
Normal file
178
internal/sandbox/macos_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
127
internal/templates/code-relaxed.json
Normal file
127
internal/templates/code-relaxed.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user