Add support for config inheritance
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user