Add code-relaxed template, handle wildcard network allow
This commit is contained in:
@@ -36,17 +36,23 @@ Run:
|
|||||||
fence --settings ./fence.json <agent-command>
|
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 |
|
| Claude Code | `code` | - |
|
||||||
| Codex | ❌ | Missing unidentified sandbox permission for interactive mode |
|
| Codex | `code` | |
|
||||||
| OpenCode | ❌ | Ignores proxy env vars; makes direct network connections |
|
| 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
|
## Protecting your environment
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ Example config:
|
|||||||
| `httpProxyPort` | Fixed port for HTTP proxy (default: random available port) |
|
| `httpProxyPort` | Fixed port for HTTP proxy (default: random available port) |
|
||||||
| `socksProxyPort` | Fixed port for SOCKS5 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
|
## Filesystem Configuration
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ You can also copy and customize templates from [`internal/templates/`](/internal
|
|||||||
| Template | Description |
|
| Template | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `code` | Production-ready config for AI coding agents (Claude Code, Codex, Copilot, 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 |
|
||||||
| `git-readonly` | Blocks destructive commands like `git push`, `rm -rf`, etc. |
|
| `git-readonly` | Blocks destructive commands like `git push`, `rm -rf`, etc. |
|
||||||
| `local-dev-server` | Allow binding and localhost outbound; allow writes to workspace/tmp |
|
| `local-dev-server` | Allow binding and localhost outbound; allow writes to workspace/tmp |
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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())
|
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",
|
||||||
@@ -282,11 +296,13 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
|||||||
"--die-with-parent",
|
"--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
|
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
|
||||||
if features.CanUnshareNet {
|
if features.CanUnshareNet && !hasWildcardAllow {
|
||||||
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
|
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")
|
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"
|
"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"
|
||||||
@@ -493,6 +494,12 @@ 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, 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
|
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
|
||||||
|
|
||||||
// Build allow paths: default + configured
|
// Build allow paths: default + configured
|
||||||
@@ -506,9 +513,18 @@ 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).
|
||||||
|
// 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{
|
params := MacOSSandboxParams{
|
||||||
Command: command,
|
Command: command,
|
||||||
NeedsNetworkRestriction: needsNetwork || len(cfg.Network.AllowedDomains) == 0, // Block if no domains allowed
|
NeedsNetworkRestriction: needsNetworkRestriction,
|
||||||
HTTPProxyPort: httpPort,
|
HTTPProxyPort: httpPort,
|
||||||
SOCKSProxyPort: socksPort,
|
SOCKSProxyPort: socksPort,
|
||||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
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)
|
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate proxy environment variables
|
|
||||||
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
|
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
|
||||||
|
|
||||||
// Build the command
|
// 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",
|
"api.together.xyz",
|
||||||
"openrouter.ai",
|
"openrouter.ai",
|
||||||
|
|
||||||
|
// Cursor API
|
||||||
|
"*.cursor.sh",
|
||||||
|
|
||||||
// Git hosting
|
// Git hosting
|
||||||
"github.com",
|
"github.com",
|
||||||
"api.github.com",
|
"api.github.com",
|
||||||
@@ -63,6 +66,9 @@
|
|||||||
// Codex state/config
|
// Codex state/config
|
||||||
"~/.codex/**",
|
"~/.codex/**",
|
||||||
|
|
||||||
|
// Cursor state/config
|
||||||
|
"~/.cursor/**",
|
||||||
|
|
||||||
// Package manager caches
|
// Package manager caches
|
||||||
"~/.npm/_cacache",
|
"~/.npm/_cacache",
|
||||||
"~/.cache",
|
"~/.cache",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var templateDescriptions = map[string]string{
|
|||||||
"local-dev-server": "Allow binding and localhost outbound; 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.",
|
"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": "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.
|
// List returns all available template names sorted alphabetically.
|
||||||
|
|||||||
Reference in New Issue
Block a user