Add support for config inheritance

This commit is contained in:
JY Tan
2026-01-05 17:23:14 -08:00
parent 83fa7a76ee
commit 800a50b457
9 changed files with 1036 additions and 129 deletions

View File

@@ -93,6 +93,7 @@ Flags:
-m, --monitor Monitor mode (shows blocked requests and violations only) -m, --monitor Monitor mode (shows blocked requests and violations only)
-p, --port Expose port for inbound connections (can be repeated) -p, --port Expose port for inbound connections (can be repeated)
-s, --settings Path to settings file (default: ~/.fence.json) -s, --settings Path to settings file (default: ~/.fence.json)
-t, --template Use built-in template (e.g., code, local-dev-server)
-v, --version Show version information -v, --version Show version information
-h, --help Help for fence -h, --help Help for fence
``` ```
@@ -104,6 +105,13 @@ Flags:
fence curl https://example.com fence curl https://example.com
# Output: curl: (56) CONNECT tunnel failed, response 403 # Output: curl: (56) CONNECT tunnel failed, response 403
# Use a built-in template
fence -t code -- claude
# Extend a template in your config (adds private registry to 'code' template)
# ~/.fence.json: {"extends": "code", "network": {"allowedDomains": ["private.company.com"]}}
fence npm install
# Use a custom config # Use a custom config
fence --settings ./my-config.json npm install fence --settings ./my-config.json npm install

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -170,6 +171,11 @@ func runCommand(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
absPath, _ := filepath.Abs(settingsPath)
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(absPath))
if err != nil {
return fmt.Errorf("failed to resolve extends: %w", err)
}
default: default:
configPath := config.DefaultConfigPath() configPath := config.DefaultConfigPath()
cfg, err = config.Load(configPath) cfg, err = config.Load(configPath)
@@ -181,6 +187,11 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath) fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
} }
cfg = config.Default() cfg = config.Default()
} else {
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(configPath))
if err != nil {
return fmt.Errorf("failed to resolve extends: %w", err)
}
} }
} }

View File

@@ -21,6 +21,66 @@ Example config:
} }
``` ```
## Config Inheritance
You can extend built-in templates or other config files using the `extends` field. This reduces boilerplate by inheriting settings from a base and only specifying your overrides.
### Extending a template
```json
{
"extends": "code",
"network": {
"allowedDomains": ["private-registry.company.com"]
}
}
```
This config:
- Inherits all settings from the `code` template (LLM providers, package registries, filesystem protections, command restrictions)
- Adds `private-registry.company.com` to the allowed domains list
### Extending a file
You can also extend other config files using absolute or relative paths:
```json
{
"extends": "./base-config.json",
"network": {
"allowedDomains": ["extra-domain.com"]
}
}
```
```json
{
"extends": "/etc/fence/company-base.json",
"filesystem": {
"denyRead": ["~/company-secrets/**"]
}
}
```
Relative paths are resolved relative to the config file's directory. The extended file is validated before merging.
### Detection
The `extends` value is treated as a file path if it contains `/` or `\`, or starts with `.`. Otherwise it's treated as a template name.
### Merge behavior
- Slice fields (domains, paths, commands) are appended and deduplicated
- Boolean fields use OR logic (true if either base or override enables it)
- Integer fields (ports) use override-wins semantics (0 keeps base value)
### Chaining
Extends chains are supported—a file can extend a template, and another file can extend that file. Circular extends are detected and rejected. Maximum chain depth is 10.
See [templates.md](templates.md) for available templates.
## Network Configuration ## Network Configuration
| Field | Description | | Field | Description |

View File

@@ -19,6 +19,67 @@ fence --list-templates
You can also copy and customize templates from [`internal/templates/`](/internal/templates/). You can also copy and customize templates from [`internal/templates/`](/internal/templates/).
## Extending templates
Instead of copying and modifying templates, you can extend them in your config file using the `extends` field:
```json
{
"extends": "code",
"network": {
"allowedDomains": ["private-registry.company.com"]
}
}
```
This inherits all settings from the `code` template and adds your private registry. Settings are merged:
- Slice fields (domains, paths, commands): Appended and deduplicated
- Boolean fields: OR logic (true if either enables it)
- Integer fields (ports): Override wins (0 keeps base value)
### Extending files
You can also extend other config files using file paths:
```json
{
"extends": "./shared/base-config.json",
"network": {
"allowedDomains": ["extra-domain.com"]
}
}
```
The `extends` value is treated as a file path if it contains `/` or `\`, or starts with `.`. Relative paths are resolved relative to the config file's directory. The extended file is validated before merging.
Chains are supported: a file can extend a template, and another file can extend that file. Circular extends are detected and rejected.
### Example: Company-specific AI agent config
```json
{
"extends": "code",
"network": {
"allowedDomains": [
"internal-npm.company.com",
"artifactory.company.com"
],
"deniedDomains": ["competitor-analytics.com"]
},
"filesystem": {
"denyRead": ["~/.company-secrets/**"]
}
}
```
This config:
- Extends the battle-tested `code` template
- Adds company-specific package registries
- Adds additional telemetry/analytics to deny list
- Protects company-specific secret directories
## Available Templates ## Available Templates
| Template | Description | | Template | Description |

View File

@@ -15,6 +15,7 @@ import (
// Config is the main configuration for fence. // Config is the main configuration for fence.
type Config struct { type Config struct {
Extends string `json:"extends,omitempty"`
Network NetworkConfig `json:"network"` Network NetworkConfig `json:"network"`
Filesystem FilesystemConfig `json:"filesystem"` Filesystem FilesystemConfig `json:"filesystem"`
Command CommandConfig `json:"command"` Command CommandConfig `json:"command"`
@@ -247,3 +248,109 @@ func MatchesDomain(hostname, pattern string) bool {
// Exact match // Exact match
return hostname == pattern return hostname == pattern
} }
// Merge combines a base config with an override config.
// Values in override take precedence. Slice fields are appended (base + override).
// The Extends field is cleared in the result since inheritance has been resolved.
func Merge(base, override *Config) *Config {
if base == nil {
if override == nil {
return Default()
}
result := *override
result.Extends = ""
return &result
}
if override == nil {
result := *base
result.Extends = ""
return &result
}
result := &Config{
// AllowPty: true if either config enables it
AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{
// Append slices (base first, then override additions)
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
// Boolean fields: override wins if set, otherwise base
AllowAllUnixSockets: base.Network.AllowAllUnixSockets || override.Network.AllowAllUnixSockets,
AllowLocalBinding: base.Network.AllowLocalBinding || override.Network.AllowLocalBinding,
// Pointer fields: override wins if set, otherwise base
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
// Port fields: override wins if non-zero
HTTPProxyPort: mergeInt(base.Network.HTTPProxyPort, override.Network.HTTPProxyPort),
SOCKSProxyPort: mergeInt(base.Network.SOCKSProxyPort, override.Network.SOCKSProxyPort),
},
Filesystem: FilesystemConfig{
// Append slices
DenyRead: mergeStrings(base.Filesystem.DenyRead, override.Filesystem.DenyRead),
AllowWrite: mergeStrings(base.Filesystem.AllowWrite, override.Filesystem.AllowWrite),
DenyWrite: mergeStrings(base.Filesystem.DenyWrite, override.Filesystem.DenyWrite),
// Boolean fields: override wins if set
AllowGitConfig: base.Filesystem.AllowGitConfig || override.Filesystem.AllowGitConfig,
},
Command: CommandConfig{
// Append slices
Deny: mergeStrings(base.Command.Deny, override.Command.Deny),
Allow: mergeStrings(base.Command.Allow, override.Command.Allow),
// Pointer field: override wins if set
UseDefaults: mergeOptionalBool(base.Command.UseDefaults, override.Command.UseDefaults),
},
}
return result
}
// mergeStrings appends two string slices, removing duplicates.
func mergeStrings(base, override []string) []string {
if len(base) == 0 {
return override
}
if len(override) == 0 {
return base
}
seen := make(map[string]bool, len(base))
result := make([]string, 0, len(base)+len(override))
for _, s := range base {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
for _, s := range override {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
// mergeOptionalBool returns override if non-nil, otherwise base.
func mergeOptionalBool(base, override *bool) *bool {
if override != nil {
return override
}
return base
}
// mergeInt returns override if non-zero, otherwise base.
func mergeInt(base, override int) int {
if override != 0 {
return override
}
return base
}

View File

@@ -291,3 +291,192 @@ func TestDefaultConfigPath(t *testing.T) {
t.Errorf("DefaultConfigPath() = %q, expected to end with .fence.json", path) t.Errorf("DefaultConfigPath() = %q, expected to end with .fence.json", path)
} }
} }
func TestMerge(t *testing.T) {
t.Run("nil base", func(t *testing.T) {
override := &Config{
AllowPty: true,
Network: NetworkConfig{
AllowedDomains: []string{"example.com"},
},
}
result := Merge(nil, override)
if !result.AllowPty {
t.Error("expected AllowPty to be true")
}
if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "example.com" {
t.Error("expected AllowedDomains to be [example.com]")
}
if result.Extends != "" {
t.Error("expected Extends to be cleared")
}
})
t.Run("nil override", func(t *testing.T) {
base := &Config{
AllowPty: true,
Network: NetworkConfig{
AllowedDomains: []string{"example.com"},
},
}
result := Merge(base, nil)
if !result.AllowPty {
t.Error("expected AllowPty to be true")
}
if len(result.Network.AllowedDomains) != 1 {
t.Error("expected AllowedDomains to be [example.com]")
}
})
t.Run("both nil", func(t *testing.T) {
result := Merge(nil, nil)
if result == nil {
t.Fatal("expected non-nil result")
}
})
t.Run("merge allowed domains", func(t *testing.T) {
base := &Config{
Network: NetworkConfig{
AllowedDomains: []string{"github.com", "api.github.com"},
},
}
override := &Config{
Extends: "base-template",
Network: NetworkConfig{
AllowedDomains: []string{"private-registry.company.com"},
},
}
result := Merge(base, override)
// Should have all three domains
if len(result.Network.AllowedDomains) != 3 {
t.Errorf("expected 3 allowed domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
// Extends should be cleared
if result.Extends != "" {
t.Errorf("expected Extends to be cleared, got %q", result.Extends)
}
})
t.Run("deduplicate merged domains", func(t *testing.T) {
base := &Config{
Network: NetworkConfig{
AllowedDomains: []string{"github.com", "example.com"},
},
}
override := &Config{
Network: NetworkConfig{
AllowedDomains: []string{"github.com", "new.com"},
},
}
result := Merge(base, override)
// Should deduplicate
if len(result.Network.AllowedDomains) != 3 {
t.Errorf("expected 3 domains (deduped), got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
})
t.Run("merge boolean flags", func(t *testing.T) {
base := &Config{
AllowPty: false,
Network: NetworkConfig{
AllowLocalBinding: true,
},
}
override := &Config{
AllowPty: true,
Network: NetworkConfig{
AllowLocalOutbound: boolPtr(true),
},
}
result := Merge(base, override)
if !result.AllowPty {
t.Error("expected AllowPty to be true (from override)")
}
if !result.Network.AllowLocalBinding {
t.Error("expected AllowLocalBinding to be true (from base)")
}
if result.Network.AllowLocalOutbound == nil || !*result.Network.AllowLocalOutbound {
t.Error("expected AllowLocalOutbound to be true (from override)")
}
})
t.Run("merge command config", func(t *testing.T) {
base := &Config{
Command: CommandConfig{
Deny: []string{"git push", "rm -rf"},
},
}
override := &Config{
Command: CommandConfig{
Deny: []string{"sudo"},
Allow: []string{"git status"},
},
}
result := Merge(base, override)
if len(result.Command.Deny) != 3 {
t.Errorf("expected 3 denied commands, got %d", len(result.Command.Deny))
}
if len(result.Command.Allow) != 1 {
t.Errorf("expected 1 allowed command, got %d", len(result.Command.Allow))
}
})
t.Run("merge filesystem config", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{
AllowWrite: []string{"."},
DenyRead: []string{"~/.ssh/**"},
},
}
override := &Config{
Filesystem: FilesystemConfig{
AllowWrite: []string{"/tmp"},
DenyWrite: []string{".env"},
},
}
result := Merge(base, override)
if len(result.Filesystem.AllowWrite) != 2 {
t.Errorf("expected 2 write paths, got %d", len(result.Filesystem.AllowWrite))
}
if len(result.Filesystem.DenyRead) != 1 {
t.Errorf("expected 1 deny read path, got %d", len(result.Filesystem.DenyRead))
}
if len(result.Filesystem.DenyWrite) != 1 {
t.Errorf("expected 1 deny write path, got %d", len(result.Filesystem.DenyWrite))
}
})
t.Run("override ports", func(t *testing.T) {
base := &Config{
Network: NetworkConfig{
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
},
}
override := &Config{
Network: NetworkConfig{
HTTPProxyPort: 9090, // override
// SOCKSProxyPort not set, should keep base
},
}
result := Merge(base, override)
if result.Network.HTTPProxyPort != 9090 {
t.Errorf("expected HTTPProxyPort 9090, got %d", result.Network.HTTPProxyPort)
}
if result.Network.SOCKSProxyPort != 1080 {
t.Errorf("expected SOCKSProxyPort 1080, got %d", result.Network.SOCKSProxyPort)
}
})
}
func boolPtr(b bool) *bool {
return &b
}

View File

@@ -1,130 +1,8 @@
{ {
"allowPty": true, "extends": "code",
"network": { "network": {
"allowLocalBinding": true, // Allow all domains directly (for apps that ignore HTTP_PROXY)
"allowLocalOutbound": true, // The "*" wildcard bypasses proxy-based domain filtering
"allowedDomains": ["*"], "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",
// Local cache, needed by tools like `uv`
"~/.cache/**",
// 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"
]
}
} }
}

View File

@@ -5,6 +5,7 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -13,6 +14,15 @@ import (
"github.com/tidwall/jsonc" "github.com/tidwall/jsonc"
) )
// maxExtendsDepth limits inheritance chain depth to prevent infinite loops.
const maxExtendsDepth = 10
// isPath returns true if the extends value looks like a file path rather than a template name.
// A value is considered a path if it contains a path separator or starts with ".".
func isPath(s string) bool {
return strings.ContainsAny(s, "/\\") || strings.HasPrefix(s, ".")
}
//go:embed *.json //go:embed *.json
var templatesFS embed.FS var templatesFS embed.FS
@@ -63,11 +73,30 @@ func List() []Template {
} }
// Load loads a template by name and returns the parsed config. // Load loads a template by name and returns the parsed config.
// If the template uses "extends", the inheritance chain is resolved.
func Load(name string) (*config.Config, error) { func Load(name string) (*config.Config, error) {
return loadWithDepth(name, 0, nil)
}
// loadWithDepth loads a template with cycle and depth tracking.
func loadWithDepth(name string, depth int, seen map[string]bool) (*config.Config, error) {
if depth > maxExtendsDepth {
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
}
// Normalize name (remove .json if present) // Normalize name (remove .json if present)
name = strings.TrimSuffix(name, ".json") name = strings.TrimSuffix(name, ".json")
filename := name + ".json"
// Check for cycles
if seen == nil {
seen = make(map[string]bool)
}
if seen[name] {
return nil, fmt.Errorf("circular extends detected: %q", name)
}
seen[name] = true
filename := name + ".json"
data, err := templatesFS.ReadFile(filename) data, err := templatesFS.ReadFile(filename)
if err != nil { if err != nil {
return nil, fmt.Errorf("template %q not found", name) return nil, fmt.Errorf("template %q not found", name)
@@ -78,6 +107,15 @@ func Load(name string) (*config.Config, error) {
return nil, fmt.Errorf("failed to parse template %q: %w", name, err) return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
} }
// If this template extends another, resolve the chain
if cfg.Extends != "" {
baseCfg, err := loadWithDepth(cfg.Extends, depth+1, seen)
if err != nil {
return nil, fmt.Errorf("failed to load base template %q: %w", cfg.Extends, err)
}
return config.Merge(baseCfg, &cfg), nil
}
return &cfg, nil return &cfg, nil
} }
@@ -95,3 +133,120 @@ func GetPath(name string) string {
name = strings.TrimSuffix(name, ".json") name = strings.TrimSuffix(name, ".json")
return filepath.Join("internal/templates", name+".json") return filepath.Join("internal/templates", name+".json")
} }
// ResolveExtends resolves the extends field in a config by loading and merging
// the base template or config file. If the config has no extends field, it is returned as-is.
// Relative paths are resolved relative to the current working directory.
// Use ResolveExtendsWithBaseDir if you need to resolve relative to a specific directory.
func ResolveExtends(cfg *config.Config) (*config.Config, error) {
return ResolveExtendsWithBaseDir(cfg, "")
}
// ResolveExtendsWithBaseDir resolves the extends field in a config.
// The baseDir is used to resolve relative paths in the extends field.
// If baseDir is empty, relative paths will be resolved relative to the current working directory.
//
// The extends field can be:
// - A template name (e.g., "code", "npm-install")
// - An absolute path (e.g., "/path/to/base.json")
// - A relative path (e.g., "./base.json", "../shared/base.json")
//
// Paths are detected by the presence of "/" or "\" or a leading ".".
func ResolveExtendsWithBaseDir(cfg *config.Config, baseDir string) (*config.Config, error) {
if cfg == nil || cfg.Extends == "" {
return cfg, nil
}
return resolveExtendsWithDepth(cfg, baseDir, 0, nil)
}
// resolveExtendsWithDepth resolves extends with cycle and depth tracking.
func resolveExtendsWithDepth(cfg *config.Config, baseDir string, depth int, seen map[string]bool) (*config.Config, error) {
if cfg == nil || cfg.Extends == "" {
return cfg, nil
}
if depth > maxExtendsDepth {
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
}
if seen == nil {
seen = make(map[string]bool)
}
var baseCfg *config.Config
var newBaseDir string
var err error
// Handle file path or template name extends
if isPath(cfg.Extends) {
baseCfg, newBaseDir, err = loadConfigFile(cfg.Extends, baseDir, seen)
} else {
baseCfg, err = loadWithDepth(cfg.Extends, depth+1, seen)
newBaseDir = ""
}
if err != nil {
return nil, err
}
// If the base config also has extends, resolve it recursively
if baseCfg.Extends != "" {
baseCfg, err = resolveExtendsWithDepth(baseCfg, newBaseDir, depth+1, seen)
if err != nil {
return nil, err
}
}
return config.Merge(baseCfg, cfg), nil
}
// loadConfigFile loads a config from a file path with cycle detection.
// Returns the loaded config, the directory of the loaded file (for resolving nested extends), and any error.
func loadConfigFile(path, baseDir string, seen map[string]bool) (*config.Config, string, error) {
var resolvedPath string
switch {
case filepath.IsAbs(path):
resolvedPath = path
case baseDir != "":
resolvedPath = filepath.Join(baseDir, path)
default:
var err error
resolvedPath, err = filepath.Abs(path)
if err != nil {
return nil, "", fmt.Errorf("failed to resolve path %q: %w", path, err)
}
}
// Clean and normalize the path for cycle detection
resolvedPath = filepath.Clean(resolvedPath)
if seen[resolvedPath] {
return nil, "", fmt.Errorf("circular extends detected: %q", path)
}
seen[resolvedPath] = true
data, err := os.ReadFile(resolvedPath) //nolint:gosec // user-provided config path - intentional
if err != nil {
if os.IsNotExist(err) {
return nil, "", fmt.Errorf("extends file not found: %q", path)
}
return nil, "", fmt.Errorf("failed to read extends file %q: %w", path, err)
}
// Handle empty file
if len(strings.TrimSpace(string(data))) == 0 {
return nil, "", fmt.Errorf("extends file is empty: %q", path)
}
var cfg config.Config
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
return nil, "", fmt.Errorf("invalid JSON in extends file %q: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return nil, "", fmt.Errorf("invalid configuration in extends file %q: %w", path, err)
}
return &cfg, filepath.Dir(resolvedPath), nil
}

View File

@@ -1,7 +1,11 @@
package templates package templates
import ( import (
"os"
"path/filepath"
"testing" "testing"
"github.com/Use-Tusk/fence/internal/config"
) )
func TestList(t *testing.T) { func TestList(t *testing.T) {
@@ -121,3 +125,437 @@ func TestCodeTemplate(t *testing.T) {
t.Error("code template should have denied commands") t.Error("code template should have denied commands")
} }
} }
func TestCodeRelaxedTemplate(t *testing.T) {
cfg, err := Load("code-relaxed")
if err != nil {
t.Fatalf("failed to load code-relaxed template: %v", err)
}
// Should inherit AllowPty from code template
if !cfg.AllowPty {
t.Error("code-relaxed should inherit AllowPty=true from code")
}
// Should have wildcard in allowed domains
hasWildcard := false
for _, domain := range cfg.Network.AllowedDomains {
if domain == "*" {
hasWildcard = true
break
}
}
if !hasWildcard {
t.Error("code-relaxed should have '*' in allowed domains")
}
// Should inherit denied domains from code
if len(cfg.Network.DeniedDomains) == 0 {
t.Error("code-relaxed should inherit denied domains from code")
}
// Should inherit filesystem config from code
if len(cfg.Filesystem.AllowWrite) == 0 {
t.Error("code-relaxed should inherit allowWrite from code")
}
if len(cfg.Filesystem.DenyRead) == 0 {
t.Error("code-relaxed should inherit denyRead from code")
}
if len(cfg.Filesystem.DenyWrite) == 0 {
t.Error("code-relaxed should inherit denyWrite from code")
}
// Should inherit command config from code
if len(cfg.Command.Deny) == 0 {
t.Error("code-relaxed should inherit command deny list from code")
}
// Extends should be cleared after resolution
if cfg.Extends != "" {
t.Error("extends should be cleared after loading")
}
}
func TestResolveExtends(t *testing.T) {
t.Run("nil config", func(t *testing.T) {
result, err := ResolveExtends(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Error("expected nil result for nil input")
}
})
t.Run("no extends", func(t *testing.T) {
cfg := &config.Config{
AllowPty: true,
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
},
}
result, err := ResolveExtends(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != cfg {
t.Error("expected same config when no extends")
}
})
t.Run("extends code template", func(t *testing.T) {
cfg := &config.Config{
Extends: "code",
Network: config.NetworkConfig{
AllowedDomains: []string{"private-registry.company.com"},
},
}
result, err := ResolveExtends(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have merged config
if result.Extends != "" {
t.Error("extends should be cleared after resolution")
}
// Should have AllowPty from base template
if !result.AllowPty {
t.Error("should inherit AllowPty from code template")
}
// Should have domains from both
hasPrivateRegistry := false
hasAnthropic := false
for _, domain := range result.Network.AllowedDomains {
if domain == "private-registry.company.com" {
hasPrivateRegistry = true
}
if domain == "*.anthropic.com" {
hasAnthropic = true
}
}
if !hasPrivateRegistry {
t.Error("should have private-registry.company.com from override")
}
if !hasAnthropic {
t.Error("should have *.anthropic.com from base template")
}
})
t.Run("extends nonexistent template", func(t *testing.T) {
cfg := &config.Config{
Extends: "nonexistent-template",
}
_, err := ResolveExtends(cfg)
if err == nil {
t.Error("expected error for nonexistent template")
}
})
}
func TestExtendsChainDepth(t *testing.T) {
// This tests that the maxExtendsDepth limit is respected.
// We can't easily create a deep chain with embedded templates,
// but we can test that the code template (which has no extends)
// loads correctly.
cfg, err := Load("code")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Error("expected non-nil config")
}
}
func TestIsPath(t *testing.T) {
tests := []struct {
input string
want bool
}{
// Template names (not paths)
{"code", false},
{"npm-install", false},
{"my-template", false},
// Absolute paths
{"/path/to/config.json", true},
{"/etc/fence/base.json", true},
// Relative paths
{"./base.json", true},
{"../shared/base.json", true},
{"configs/base.json", true},
// Windows-style paths
{"C:\\path\\to\\config.json", true},
{".\\base.json", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isPath(tt.input)
if got != tt.want {
t.Errorf("isPath(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestExtendsFilePath(t *testing.T) {
// Create temp directory for test files
tmpDir := t.TempDir()
t.Run("extends absolute path", func(t *testing.T) {
// Create base config file
baseContent := `{
"network": {
"allowedDomains": ["base.example.com"]
},
"filesystem": {
"allowWrite": ["/tmp"]
}
}`
basePath := filepath.Join(tmpDir, "base.json")
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
t.Fatalf("failed to write base config: %v", err)
}
// Config that extends the base via absolute path
cfg := &config.Config{
Extends: basePath,
Network: config.NetworkConfig{
AllowedDomains: []string{"override.example.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have merged domains
if len(result.Network.AllowedDomains) != 2 {
t.Errorf("expected 2 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
// Should have filesystem from base
if len(result.Filesystem.AllowWrite) != 1 || result.Filesystem.AllowWrite[0] != "/tmp" {
t.Errorf("expected AllowWrite [/tmp], got %v", result.Filesystem.AllowWrite)
}
})
t.Run("extends relative path", func(t *testing.T) {
// Create base config in subdir
subDir := filepath.Join(tmpDir, "configs")
if err := os.MkdirAll(subDir, 0o750); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
baseContent := `{
"allowPty": true,
"network": {
"allowedDomains": ["relative-base.example.com"]
}
}`
basePath := filepath.Join(subDir, "base.json")
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
t.Fatalf("failed to write base config: %v", err)
}
// Config that extends via relative path
cfg := &config.Config{
Extends: "./configs/base.json",
Network: config.NetworkConfig{
AllowedDomains: []string{"child.example.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, tmpDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should inherit AllowPty
if !result.AllowPty {
t.Error("should inherit AllowPty from base")
}
// Should have merged domains
if len(result.Network.AllowedDomains) != 2 {
t.Errorf("expected 2 domains, got %d", len(result.Network.AllowedDomains))
}
})
t.Run("extends nonexistent file", func(t *testing.T) {
cfg := &config.Config{
Extends: "/nonexistent/path/config.json",
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for nonexistent file")
}
})
t.Run("extends invalid JSON file", func(t *testing.T) {
invalidPath := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o600); err != nil {
t.Fatalf("failed to write invalid config: %v", err)
}
cfg := &config.Config{
Extends: invalidPath,
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for invalid JSON")
}
})
t.Run("extends file with invalid config", func(t *testing.T) {
// Create config with invalid domain pattern
invalidContent := `{
"network": {
"allowedDomains": ["*.com"]
}
}`
invalidPath := filepath.Join(tmpDir, "invalid-domain.json")
if err := os.WriteFile(invalidPath, []byte(invalidContent), 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg := &config.Config{
Extends: invalidPath,
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for invalid config")
}
})
t.Run("circular extends via files", func(t *testing.T) {
// Create two files that extend each other
fileA := filepath.Join(tmpDir, "a.json")
fileB := filepath.Join(tmpDir, "b.json")
contentA := `{"extends": "` + fileB + `"}`
contentB := `{"extends": "` + fileA + `"}`
if err := os.WriteFile(fileA, []byte(contentA), 0o600); err != nil {
t.Fatalf("failed to write a.json: %v", err)
}
if err := os.WriteFile(fileB, []byte(contentB), 0o600); err != nil {
t.Fatalf("failed to write b.json: %v", err)
}
cfg := &config.Config{
Extends: fileA,
}
_, err := ResolveExtendsWithBaseDir(cfg, "")
if err == nil {
t.Error("expected error for circular extends")
}
})
t.Run("nested extends chain", func(t *testing.T) {
// Create a chain: child -> middle -> base
baseContent := `{
"network": {
"allowedDomains": ["base.com"]
}
}`
basePath := filepath.Join(tmpDir, "chain-base.json")
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
t.Fatalf("failed to write base: %v", err)
}
middleContent := `{
"extends": "` + basePath + `",
"network": {
"allowedDomains": ["middle.com"]
}
}`
middlePath := filepath.Join(tmpDir, "chain-middle.json")
if err := os.WriteFile(middlePath, []byte(middleContent), 0o600); err != nil {
t.Fatalf("failed to write middle: %v", err)
}
cfg := &config.Config{
Extends: middlePath,
Network: config.NetworkConfig{
AllowedDomains: []string{"child.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have all three domains
if len(result.Network.AllowedDomains) != 3 {
t.Errorf("expected 3 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
}
})
t.Run("file extends template", func(t *testing.T) {
// Create a file that extends a built-in template
fileContent := `{
"extends": "code",
"network": {
"allowedDomains": ["extra.example.com"]
}
}`
filePath := filepath.Join(tmpDir, "extends-template.json")
if err := os.WriteFile(filePath, []byte(fileContent), 0o600); err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Config that extends this file
cfg := &config.Config{
Extends: filePath,
Network: config.NetworkConfig{
AllowedDomains: []string{"top.example.com"},
},
}
result, err := ResolveExtendsWithBaseDir(cfg, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have AllowPty from code template
if !result.AllowPty {
t.Error("should inherit AllowPty from code template")
}
// Should have domains from all levels
hasAnthropic := false
hasExtra := false
hasTop := false
for _, domain := range result.Network.AllowedDomains {
switch domain {
case "*.anthropic.com":
hasAnthropic = true
case "extra.example.com":
hasExtra = true
case "top.example.com":
hasTop = true
}
}
if !hasAnthropic {
t.Error("should have *.anthropic.com from code template")
}
if !hasExtra {
t.Error("should have extra.example.com from middle file")
}
if !hasTop {
t.Error("should have top.example.com from top config")
}
})
}