Introduce built-in templates for enhanced configuration options, support JSONC format

This commit is contained in:
JY Tan
2025-12-28 22:16:50 -08:00
parent 8317bb96bc
commit d8e55d9515
22 changed files with 655 additions and 83 deletions

View File

@@ -9,6 +9,8 @@ import (
"path/filepath"
"slices"
"strings"
"github.com/tidwall/jsonc"
)
// Config is the main configuration for fence.
@@ -134,7 +136,7 @@ func Load(path string) (*Config, error) {
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
return nil, fmt.Errorf("invalid JSON in config file: %w", err)
}
@@ -231,6 +233,11 @@ 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:]

View File

@@ -170,6 +170,41 @@ func TestCreateDomainFilter(t *testing.T) {
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 {

View File

@@ -307,6 +307,9 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.securityd.xpc")
(global-name "com.apple.coreservices.launchservicesd")
(global-name "com.apple.FSEvents")
(global-name "com.apple.fseventsd")
(global-name "com.apple.SystemConfiguration.configd")
)
; POSIX IPC

View File

@@ -0,0 +1,157 @@
{
"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",
// Git hosting
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.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",
// 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/**"
],
"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

@@ -0,0 +1,116 @@
{
"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",
// 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

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

View File

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

View File

@@ -0,0 +1,96 @@
// Package templates provides embedded configuration templates for fence.
package templates
import (
"embed"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/Use-Tusk/fence/internal/config"
"github.com/tidwall/jsonc"
)
//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.)",
}
// 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.
func Load(name string) (*config.Config, error) {
// Normalize name (remove .json if present)
name = strings.TrimSuffix(name, ".json")
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)
}
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")
}

View File

@@ -0,0 +1,123 @@
package templates
import (
"testing"
)
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")
}
}