diff --git a/docs/agents.md b/docs/agents.md index 0c21c2f..d7d2b9c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -36,17 +36,23 @@ Run: fence --settings ./fence.json ``` -## 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 diff --git a/docs/configuration.md b/docs/configuration.md index 684b86b..5bac433 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/docs/templates.md b/docs/templates.md index 025df77..29cab9a 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -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 | diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index cecc529..4d682a2 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -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") } diff --git a/internal/sandbox/linux_test.go b/internal/sandbox/linux_test.go new file mode 100644 index 0000000..6305200 --- /dev/null +++ b/internal/sandbox/linux_test.go @@ -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) + } + }) + } +} diff --git a/internal/sandbox/macos.go b/internal/sandbox/macos.go index fbb1218..10f3b35 100644 --- a/internal/sandbox/macos.go +++ b/internal/sandbox/macos.go @@ -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 diff --git a/internal/sandbox/macos_test.go b/internal/sandbox/macos_test.go new file mode 100644 index 0000000..ae019eb --- /dev/null +++ b/internal/sandbox/macos_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/templates/code-relaxed.json b/internal/templates/code-relaxed.json new file mode 100644 index 0000000..aa56b5a --- /dev/null +++ b/internal/templates/code-relaxed.json @@ -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" + ] + } + } + \ No newline at end of file diff --git a/internal/templates/code.json b/internal/templates/code.json index b803227..848f443 100644 --- a/internal/templates/code.json +++ b/internal/templates/code.json @@ -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", diff --git a/internal/templates/templates.go b/internal/templates/templates.go index e89147f..34c59de 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -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.