Introduce built-in templates for enhanced configuration options, support JSONC format
This commit is contained in:
@@ -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:]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
157
internal/templates/code.json
Normal file
157
internal/templates/code.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
116
internal/templates/disable-telemetry.json
Normal file
116
internal/templates/disable-telemetry.json
Normal 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/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
19
internal/templates/git-readonly.json
Normal file
19
internal/templates/git-readonly.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
9
internal/templates/local-dev-server.json
Normal file
9
internal/templates/local-dev-server.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [".", "/tmp"]
|
||||
}
|
||||
}
|
||||
96
internal/templates/templates.go
Normal file
96
internal/templates/templates.go
Normal 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")
|
||||
}
|
||||
123
internal/templates/templates_test.go
Normal file
123
internal/templates/templates_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user