diff --git a/README.md b/README.md index bcca205..9384139 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Flags: -m, --monitor Monitor mode (shows blocked requests and violations only) -p, --port Expose port for inbound connections (can be repeated) -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 -h, --help Help for fence ``` @@ -104,6 +105,13 @@ Flags: fence curl https://example.com # 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 fence --settings ./my-config.json npm install diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 54f1106..71151a5 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "strconv" "strings" "syscall" @@ -170,6 +171,11 @@ func runCommand(cmd *cobra.Command, args []string) error { if err != nil { 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: configPath := config.DefaultConfigPath() 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) } cfg = config.Default() + } else { + cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(configPath)) + if err != nil { + return fmt.Errorf("failed to resolve extends: %w", err) + } } } diff --git a/docs/configuration.md b/docs/configuration.md index 5bac433..9427ddb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | Field | Description | diff --git a/docs/templates.md b/docs/templates.md index 29cab9a..b7b7331 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -19,6 +19,67 @@ fence --list-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 | Template | Description | diff --git a/internal/config/config.go b/internal/config/config.go index 51f3fa8..6fb67f2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( // Config is the main configuration for fence. type Config struct { + Extends string `json:"extends,omitempty"` Network NetworkConfig `json:"network"` Filesystem FilesystemConfig `json:"filesystem"` Command CommandConfig `json:"command"` @@ -247,3 +248,109 @@ func MatchesDomain(hostname, pattern string) bool { // Exact match 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cf48692..6ca30c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -291,3 +291,192 @@ func TestDefaultConfigPath(t *testing.T) { 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 +} diff --git a/internal/templates/code-relaxed.json b/internal/templates/code-relaxed.json index 4d18d41..5462ef4 100644 --- a/internal/templates/code-relaxed.json +++ b/internal/templates/code-relaxed.json @@ -1,130 +1,8 @@ { - "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", - - // 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" - ] - } + "extends": "code", + "network": { + // Allow all domains directly (for apps that ignore HTTP_PROXY) + // The "*" wildcard bypasses proxy-based domain filtering + "allowedDomains": ["*"] } - \ No newline at end of file +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 34c59de..522e06b 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -5,6 +5,7 @@ import ( "embed" "encoding/json" "fmt" + "os" "path/filepath" "sort" "strings" @@ -13,6 +14,15 @@ import ( "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 var templatesFS embed.FS @@ -63,11 +73,30 @@ func List() []Template { } // 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) { + 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) 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) if err != nil { 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) } + // 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 } @@ -95,3 +133,120 @@ func GetPath(name string) string { name = strings.TrimSuffix(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 +} diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go index 123d4d2..e55660d 100644 --- a/internal/templates/templates_test.go +++ b/internal/templates/templates_test.go @@ -1,7 +1,11 @@ package templates import ( + "os" + "path/filepath" "testing" + + "github.com/Use-Tusk/fence/internal/config" ) func TestList(t *testing.T) { @@ -121,3 +125,437 @@ func TestCodeTemplate(t *testing.T) { 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") + } + }) +}