feat: ability to import claude code settings as configs (#7)

This commit is contained in:
JY Tan
2026-01-15 14:55:44 -08:00
committed by GitHub
parent 800a50b457
commit f3ac2d72f4
7 changed files with 1183 additions and 0 deletions

444
internal/importer/claude.go Normal file
View File

@@ -0,0 +1,444 @@
// Package importer provides functionality to import settings from other tools.
package importer
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Use-Tusk/fence/internal/config"
"github.com/tidwall/jsonc"
)
// ClaudeSettings represents the Claude Code settings.json structure.
type ClaudeSettings struct {
Permissions ClaudePermissions `json:"permissions"`
}
// ClaudePermissions represents the permissions block in Claude Code settings.
type ClaudePermissions struct {
Allow []string `json:"allow"`
Deny []string `json:"deny"`
Ask []string `json:"ask"`
}
// ClaudeSettingsPaths returns the standard paths where Claude Code stores settings.
func ClaudeSettingsPaths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
paths := []string{
filepath.Join(home, ".claude", "settings.json"),
}
// Also check project-level settings in current directory
cwd, err := os.Getwd()
if err == nil {
paths = append(paths,
filepath.Join(cwd, ".claude", "settings.json"),
filepath.Join(cwd, ".claude", "settings.local.json"),
)
}
return paths
}
// DefaultClaudeSettingsPath returns the default user-level Claude settings path.
func DefaultClaudeSettingsPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude", "settings.json")
}
// LoadClaudeSettings loads Claude Code settings from a file.
func LoadClaudeSettings(path string) (*ClaudeSettings, error) {
data, err := os.ReadFile(path) //nolint:gosec // user-provided path - intentional
if err != nil {
return nil, fmt.Errorf("failed to read Claude settings: %w", err)
}
// Handle empty file
if len(strings.TrimSpace(string(data))) == 0 {
return &ClaudeSettings{}, nil
}
var settings ClaudeSettings
if err := json.Unmarshal(jsonc.ToJSON(data), &settings); err != nil {
return nil, fmt.Errorf("invalid JSON in Claude settings: %w", err)
}
return &settings, nil
}
// ConvertClaudeToFence converts Claude Code settings to a fence config.
func ConvertClaudeToFence(settings *ClaudeSettings) *config.Config {
cfg := config.Default()
// Process allow rules
for _, rule := range settings.Permissions.Allow {
processClaudeRule(rule, cfg, true)
}
// Process deny rules
for _, rule := range settings.Permissions.Deny {
processClaudeRule(rule, cfg, false)
}
// Process ask rules (treat as deny for fence, since fence doesn't have interactive prompts)
// Users can review and move to allow if needed
for _, rule := range settings.Permissions.Ask {
processClaudeRule(rule, cfg, false)
}
return cfg
}
// bashPattern matches Bash permission rules like "Bash(npm run test:*)" or "Bash(curl:*)"
var bashPattern = regexp.MustCompile(`^Bash\((.+)\)$`)
// readPattern matches Read permission rules like "Read(./.env)" or "Read(./secrets/**)"
var readPattern = regexp.MustCompile(`^Read\((.+)\)$`)
// writePattern matches Write permission rules like "Write(./output/**)"
var writePattern = regexp.MustCompile(`^Write\((.+)\)$`)
// editPattern matches Edit permission rules (similar to Write)
var editPattern = regexp.MustCompile(`^Edit\((.+)\)$`)
// processClaudeRule processes a single Claude permission rule and updates the fence config.
func processClaudeRule(rule string, cfg *config.Config, isAllow bool) {
rule = strings.TrimSpace(rule)
if rule == "" {
return
}
// Handle Bash(command) rules
if matches := bashPattern.FindStringSubmatch(rule); len(matches) == 2 {
cmd := normalizeClaudeCommand(matches[1])
if cmd != "" {
if isAllow {
cfg.Command.Allow = appendUnique(cfg.Command.Allow, cmd)
} else {
cfg.Command.Deny = appendUnique(cfg.Command.Deny, cmd)
}
}
return
}
// Handle Read(path) rules
if matches := readPattern.FindStringSubmatch(rule); len(matches) == 2 {
path := normalizeClaudePath(matches[1])
if path != "" {
if !isAllow {
// Read deny -> filesystem.denyRead
cfg.Filesystem.DenyRead = appendUnique(cfg.Filesystem.DenyRead, path)
}
// Note: fence doesn't have an "allowRead" concept - everything is readable by default
}
return
}
// Handle Write(path) rules
if matches := writePattern.FindStringSubmatch(rule); len(matches) == 2 {
path := normalizeClaudePath(matches[1])
if path != "" {
if isAllow {
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
} else {
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
}
}
return
}
// Handle Edit(path) rules (same as Write)
if matches := editPattern.FindStringSubmatch(rule); len(matches) == 2 {
path := normalizeClaudePath(matches[1])
if path != "" {
if isAllow {
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
} else {
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
}
}
return
}
// Handle bare tool names (e.g., "Read", "Write", "Bash")
// These are global permissions that don't map directly to fence's path-based model
// We skip them as they don't provide actionable path/command restrictions
}
// normalizeClaudeCommand converts Claude's command format to fence format.
// Claude uses "npm:*" style, fence uses "npm" for prefix matching.
func normalizeClaudeCommand(cmd string) string {
cmd = strings.TrimSpace(cmd)
// Handle wildcard patterns like "npm:*" -> "npm"
// Claude uses ":" as separator, fence uses space-separated commands
// Also handles "npm run test:*" -> "npm run test"
cmd = strings.TrimSuffix(cmd, ":*")
return cmd
}
// normalizeClaudePath converts Claude's path format to fence format.
func normalizeClaudePath(path string) string {
path = strings.TrimSpace(path)
// Claude uses ./ prefix for relative paths, fence doesn't require it
// but fence does support it, so we can keep it
// Convert ** glob patterns - both Claude and fence support these
// No conversion needed
return path
}
// appendUnique appends a value to a slice if it's not already present.
func appendUnique(slice []string, value string) []string {
for _, v := range slice {
if v == value {
return slice
}
}
return append(slice, value)
}
// ImportResult contains the result of an import operation.
type ImportResult struct {
Config *config.Config
SourcePath string
RulesImported int
Warnings []string
}
// ImportOptions configures the import behavior.
type ImportOptions struct {
// Extends specifies a template or file to extend. Empty string means no extends.
Extends string
}
// DefaultImportOptions returns the default import options.
// By default, imports extend the "code" template for sensible defaults.
func DefaultImportOptions() ImportOptions {
return ImportOptions{
Extends: "code",
}
}
// ImportFromClaude imports settings from Claude Code and returns a fence config.
// If path is empty, it tries the default Claude settings path.
func ImportFromClaude(path string, opts ImportOptions) (*ImportResult, error) {
if path == "" {
path = DefaultClaudeSettingsPath()
}
if path == "" {
return nil, fmt.Errorf("could not determine Claude settings path")
}
settings, err := LoadClaudeSettings(path)
if err != nil {
return nil, err
}
cfg := ConvertClaudeToFence(settings)
// Set extends if specified
if opts.Extends != "" {
cfg.Extends = opts.Extends
}
result := &ImportResult{
Config: cfg,
SourcePath: path,
RulesImported: len(settings.Permissions.Allow) +
len(settings.Permissions.Deny) +
len(settings.Permissions.Ask),
}
// Add warnings for rules that couldn't be fully converted
for _, rule := range settings.Permissions.Allow {
if isGlobalToolRule(rule) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
}
}
for _, rule := range settings.Permissions.Deny {
if isGlobalToolRule(rule) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
}
}
for _, rule := range settings.Permissions.Ask {
if isGlobalToolRule(rule) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
} else {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Ask rule %q converted to deny (fence doesn't support interactive prompts)", rule))
}
}
return result, nil
}
// isGlobalToolRule checks if a rule is a global tool permission (no path/command specified).
func isGlobalToolRule(rule string) bool {
rule = strings.TrimSpace(rule)
// Global rules are bare tool names without parentheses
return !strings.Contains(rule, "(")
}
// cleanNetworkConfig is used for JSON output with omitempty to skip empty fields.
type cleanNetworkConfig struct {
AllowedDomains []string `json:"allowedDomains,omitempty"`
DeniedDomains []string `json:"deniedDomains,omitempty"`
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"`
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
}
// cleanFilesystemConfig is used for JSON output with omitempty to skip empty fields.
type cleanFilesystemConfig struct {
DenyRead []string `json:"denyRead,omitempty"`
AllowWrite []string `json:"allowWrite,omitempty"`
DenyWrite []string `json:"denyWrite,omitempty"`
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
}
// cleanCommandConfig is used for JSON output with omitempty to skip empty fields.
type cleanCommandConfig struct {
Deny []string `json:"deny,omitempty"`
Allow []string `json:"allow,omitempty"`
UseDefaults *bool `json:"useDefaults,omitempty"`
}
// cleanConfig is used for JSON output with fields in desired order and omitempty.
type cleanConfig struct {
Extends string `json:"extends,omitempty"`
AllowPty bool `json:"allowPty,omitempty"`
Network *cleanNetworkConfig `json:"network,omitempty"`
Filesystem *cleanFilesystemConfig `json:"filesystem,omitempty"`
Command *cleanCommandConfig `json:"command,omitempty"`
}
// MarshalConfigJSON marshals a fence config to clean JSON, omitting empty arrays
// and with fields in a logical order (extends first).
func MarshalConfigJSON(cfg *config.Config) ([]byte, error) {
clean := cleanConfig{
Extends: cfg.Extends,
AllowPty: cfg.AllowPty,
}
// Network config - only include if non-empty
network := cleanNetworkConfig{
AllowedDomains: cfg.Network.AllowedDomains,
DeniedDomains: cfg.Network.DeniedDomains,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: cfg.Network.AllowLocalBinding,
AllowLocalOutbound: cfg.Network.AllowLocalOutbound,
HTTPProxyPort: cfg.Network.HTTPProxyPort,
SOCKSProxyPort: cfg.Network.SOCKSProxyPort,
}
if !isNetworkEmpty(network) {
clean.Network = &network
}
// Filesystem config - only include if non-empty
filesystem := cleanFilesystemConfig{
DenyRead: cfg.Filesystem.DenyRead,
AllowWrite: cfg.Filesystem.AllowWrite,
DenyWrite: cfg.Filesystem.DenyWrite,
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
}
if !isFilesystemEmpty(filesystem) {
clean.Filesystem = &filesystem
}
// Command config - only include if non-empty
command := cleanCommandConfig{
Deny: cfg.Command.Deny,
Allow: cfg.Command.Allow,
UseDefaults: cfg.Command.UseDefaults,
}
if !isCommandEmpty(command) {
clean.Command = &command
}
return json.MarshalIndent(clean, "", " ")
}
func isNetworkEmpty(n cleanNetworkConfig) bool {
return len(n.AllowedDomains) == 0 &&
len(n.DeniedDomains) == 0 &&
len(n.AllowUnixSockets) == 0 &&
!n.AllowAllUnixSockets &&
!n.AllowLocalBinding &&
n.AllowLocalOutbound == nil &&
n.HTTPProxyPort == 0 &&
n.SOCKSProxyPort == 0
}
func isFilesystemEmpty(f cleanFilesystemConfig) bool {
return len(f.DenyRead) == 0 &&
len(f.AllowWrite) == 0 &&
len(f.DenyWrite) == 0 &&
!f.AllowGitConfig
}
func isCommandEmpty(c cleanCommandConfig) bool {
return len(c.Deny) == 0 &&
len(c.Allow) == 0 &&
c.UseDefaults == nil
}
// FormatConfigWithComment returns the config JSON with a comment header
// explaining that values are inherited from the extended template.
func FormatConfigWithComment(cfg *config.Config) (string, error) {
data, err := MarshalConfigJSON(cfg)
if err != nil {
return "", err
}
var output strings.Builder
// Add comment about inherited values if extending a template
if cfg.Extends != "" {
output.WriteString(fmt.Sprintf("// This config extends %q.\n", cfg.Extends))
output.WriteString(fmt.Sprintf("// Network, filesystem, and command rules from %q are inherited.\n", cfg.Extends))
output.WriteString("// Only your additional rules are shown below.\n")
output.WriteString("// Run `fence --list-templates` to see available templates.\n")
}
output.Write(data)
output.WriteByte('\n')
return output.String(), nil
}
// WriteConfig writes a fence config to a file.
func WriteConfig(cfg *config.Config, path string) error {
output, err := FormatConfigWithComment(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(path, []byte(output), 0o644); err != nil { //nolint:gosec // config file permissions
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}

View File

@@ -0,0 +1,581 @@
package importer
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Use-Tusk/fence/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertClaudeToFence(t *testing.T) {
tests := []struct {
name string
settings *ClaudeSettings
wantCmd struct {
allow []string
deny []string
}
wantFS struct {
denyRead []string
allowWrite []string
denyWrite []string
}
}{
{
name: "empty settings",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{},
},
wantCmd: struct {
allow []string
deny []string
}{},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{},
},
{
name: "bash allow rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Bash(npm run lint)",
"Bash(npm run test:*)",
"Bash(git status)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
allow: []string{"npm run lint", "npm run test", "git status"},
},
},
{
name: "bash deny rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Deny: []string{
"Bash(curl:*)",
"Bash(sudo:*)",
"Bash(rm -rf /)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
deny: []string{"curl", "sudo", "rm -rf /"},
},
},
{
name: "read deny rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Deny: []string{
"Read(./.env)",
"Read(./secrets/**)",
"Read(~/.ssh/*)",
},
},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyRead: []string{"./.env", "./secrets/**", "~/.ssh/*"},
},
},
{
name: "write allow rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Write(./output/**)",
"Write(./build)",
},
},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
allowWrite: []string{"./output/**", "./build"},
},
},
{
name: "write deny rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Deny: []string{
"Write(./.git/**)",
"Edit(./package-lock.json)",
},
},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyWrite: []string{"./.git/**", "./package-lock.json"},
},
},
{
name: "ask rules converted to deny",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Ask: []string{
"Write(./config.json)",
"Bash(npm publish)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
deny: []string{"npm publish"},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyWrite: []string{"./config.json"},
},
},
{
name: "global tool rules are skipped",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Read",
"Grep",
"LS",
"Bash(npm run build)", // This should be included
},
Deny: []string{
"Edit",
"Bash(sudo:*)", // This should be included
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
allow: []string{"npm run build"},
deny: []string{"sudo"},
},
},
{
name: "mixed rules",
settings: &ClaudeSettings{
Permissions: ClaudePermissions{
Allow: []string{
"Bash(npm install)",
"Bash(npm run:*)",
"Write(./dist/**)",
},
Deny: []string{
"Bash(curl:*)",
"Read(./.env)",
"Write(./.git/**)",
},
Ask: []string{
"Bash(git push)",
},
},
},
wantCmd: struct {
allow []string
deny []string
}{
allow: []string{"npm install", "npm run"},
deny: []string{"curl", "git push"},
},
wantFS: struct {
denyRead []string
allowWrite []string
denyWrite []string
}{
denyRead: []string{"./.env"},
allowWrite: []string{"./dist/**"},
denyWrite: []string{"./.git/**"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := ConvertClaudeToFence(tt.settings)
assert.ElementsMatch(t, tt.wantCmd.allow, cfg.Command.Allow, "command.allow mismatch")
assert.ElementsMatch(t, tt.wantCmd.deny, cfg.Command.Deny, "command.deny mismatch")
assert.ElementsMatch(t, tt.wantFS.denyRead, cfg.Filesystem.DenyRead, "filesystem.denyRead mismatch")
assert.ElementsMatch(t, tt.wantFS.allowWrite, cfg.Filesystem.AllowWrite, "filesystem.allowWrite mismatch")
assert.ElementsMatch(t, tt.wantFS.denyWrite, cfg.Filesystem.DenyWrite, "filesystem.denyWrite mismatch")
})
}
}
func TestNormalizeClaudeCommand(t *testing.T) {
tests := []struct {
input string
want string
}{
{"npm:*", "npm"},
{"curl:*", "curl"},
{"npm run test:*", "npm run test"},
{"git status", "git status"},
{"sudo rm -rf", "sudo rm -rf"},
{"", ""},
{" npm ", "npm"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeClaudeCommand(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
func TestLoadClaudeSettings(t *testing.T) {
t.Run("valid settings", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)", "Read"],
"deny": ["Bash(sudo:*)"],
"ask": ["Write"]
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
settings, err := LoadClaudeSettings(settingsPath)
require.NoError(t, err)
assert.Equal(t, []string{"Bash(npm install)", "Read"}, settings.Permissions.Allow)
assert.Equal(t, []string{"Bash(sudo:*)"}, settings.Permissions.Deny)
assert.Equal(t, []string{"Write"}, settings.Permissions.Ask)
})
t.Run("settings with comments (JSONC)", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
// This is a comment
"permissions": {
"allow": ["Bash(npm install)"],
"deny": [], // Another comment
"ask": []
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
settings, err := LoadClaudeSettings(settingsPath)
require.NoError(t, err)
assert.Equal(t, []string{"Bash(npm install)"}, settings.Permissions.Allow)
})
t.Run("empty file", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
err := os.WriteFile(settingsPath, []byte(""), 0o600) //nolint:gosec // test file
require.NoError(t, err)
settings, err := LoadClaudeSettings(settingsPath)
require.NoError(t, err)
assert.NotNil(t, settings)
})
t.Run("file not found", func(t *testing.T) {
_, err := LoadClaudeSettings("/nonexistent/path/settings.json")
assert.Error(t, err)
})
t.Run("invalid json", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
err := os.WriteFile(settingsPath, []byte("not json"), 0o600) //nolint:gosec // test file
require.NoError(t, err)
_, err = LoadClaudeSettings(settingsPath)
assert.Error(t, err)
})
}
func TestImportFromClaude(t *testing.T) {
t.Run("successful import with default extends", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)", "Write(./dist/**)"],
"deny": ["Bash(curl:*)", "Read(./.env)"],
"ask": ["Bash(git push)"]
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
require.NoError(t, err)
assert.Equal(t, settingsPath, result.SourcePath)
assert.Equal(t, 5, result.RulesImported)
assert.Equal(t, "code", result.Config.Extends) // default extends
// Check converted config
assert.Contains(t, result.Config.Command.Allow, "npm install")
assert.Contains(t, result.Config.Command.Deny, "curl")
assert.Contains(t, result.Config.Command.Deny, "git push") // ask -> deny
assert.Contains(t, result.Config.Filesystem.AllowWrite, "./dist/**")
assert.Contains(t, result.Config.Filesystem.DenyRead, "./.env")
})
t.Run("import with no extend", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)"],
"deny": [],
"ask": []
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
opts := ImportOptions{Extends: ""}
result, err := ImportFromClaude(settingsPath, opts)
require.NoError(t, err)
assert.Equal(t, "", result.Config.Extends) // no extends
assert.Contains(t, result.Config.Command.Allow, "npm install")
})
t.Run("import with custom extend", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Bash(npm install)"],
"deny": [],
"ask": []
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
opts := ImportOptions{Extends: "local-dev-server"}
result, err := ImportFromClaude(settingsPath, opts)
require.NoError(t, err)
assert.Equal(t, "local-dev-server", result.Config.Extends)
})
t.Run("warnings for global rules", func(t *testing.T) {
tmpDir := t.TempDir()
settingsPath := filepath.Join(tmpDir, "settings.json")
content := `{
"permissions": {
"allow": ["Read", "Grep", "Bash(npm install)"],
"deny": ["Edit"],
"ask": ["Write"]
}
}`
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
require.NoError(t, err)
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
require.NoError(t, err)
// Should have warnings for global rules: Read, Grep, Edit, Write (all global)
assert.Len(t, result.Warnings, 4)
// Verify the warnings mention the right rules
warningsStr := strings.Join(result.Warnings, " ")
assert.Contains(t, warningsStr, "Read")
assert.Contains(t, warningsStr, "Grep")
assert.Contains(t, warningsStr, "Edit")
assert.Contains(t, warningsStr, "Write")
assert.Contains(t, warningsStr, "skipped")
})
}
func TestWriteConfig(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "fence.json")
cfg := &config.Config{}
cfg.Command.Allow = []string{"npm install"}
cfg.Command.Deny = []string{"curl"}
cfg.Filesystem.DenyRead = []string{"./.env"}
err := WriteConfig(cfg, outputPath)
require.NoError(t, err)
// Verify the file was written correctly
data, err := os.ReadFile(outputPath) //nolint:gosec // test reads file we just wrote
require.NoError(t, err)
assert.Contains(t, string(data), `"npm install"`)
assert.Contains(t, string(data), `"curl"`)
assert.Contains(t, string(data), `"./.env"`)
}
func TestMarshalConfigJSON(t *testing.T) {
t.Run("omits empty arrays", func(t *testing.T) {
cfg := &config.Config{}
cfg.Command.Allow = []string{"npm install"}
// Leave all other arrays empty
data, err := MarshalConfigJSON(cfg)
require.NoError(t, err)
output := string(data)
assert.Contains(t, output, `"npm install"`)
assert.NotContains(t, output, `"allowedDomains"`)
assert.NotContains(t, output, `"deniedDomains"`)
assert.NotContains(t, output, `"denyRead"`)
assert.NotContains(t, output, `"allowWrite"`)
assert.NotContains(t, output, `"denyWrite"`)
assert.NotContains(t, output, `"network"`) // entire network section should be omitted
})
t.Run("includes extends field", func(t *testing.T) {
cfg := &config.Config{}
cfg.Extends = "code"
cfg.Command.Allow = []string{"npm install"}
data, err := MarshalConfigJSON(cfg)
require.NoError(t, err)
output := string(data)
assert.Contains(t, output, `"extends": "code"`)
})
t.Run("includes non-empty arrays", func(t *testing.T) {
cfg := &config.Config{}
cfg.Network.AllowedDomains = []string{"example.com"}
cfg.Filesystem.DenyRead = []string{".env"}
cfg.Command.Deny = []string{"sudo"}
data, err := MarshalConfigJSON(cfg)
require.NoError(t, err)
output := string(data)
assert.Contains(t, output, `"example.com"`)
assert.Contains(t, output, `".env"`)
assert.Contains(t, output, `"sudo"`)
})
}
func TestFormatConfigWithComment(t *testing.T) {
t.Run("adds comment when extends is set", func(t *testing.T) {
cfg := &config.Config{}
cfg.Extends = "code"
cfg.Command.Allow = []string{"npm install"}
output, err := FormatConfigWithComment(cfg)
require.NoError(t, err)
assert.Contains(t, output, `// This config extends "code".`)
assert.Contains(t, output, `// Network, filesystem, and command rules from "code" are inherited.`)
assert.Contains(t, output, `"npm install"`)
})
t.Run("no comment when extends is empty", func(t *testing.T) {
cfg := &config.Config{}
cfg.Command.Allow = []string{"npm install"}
output, err := FormatConfigWithComment(cfg)
require.NoError(t, err)
assert.NotContains(t, output, "//")
assert.Contains(t, output, `"npm install"`)
})
}
func TestIsGlobalToolRule(t *testing.T) {
tests := []struct {
rule string
expected bool
}{
{"Read", true},
{"Write", true},
{"Grep", true},
{"LS", true},
{"Bash", true},
{"Read(./.env)", false},
{"Write(./dist/**)", false},
{"Bash(npm install)", false},
{"Bash(curl:*)", false},
}
for _, tt := range tests {
t.Run(tt.rule, func(t *testing.T) {
assert.Equal(t, tt.expected, isGlobalToolRule(tt.rule))
})
}
}
func TestAppendUnique(t *testing.T) {
tests := []struct {
name string
slice []string
value string
expected []string
}{
{
name: "append to empty",
slice: []string{},
value: "a",
expected: []string{"a"},
},
{
name: "append new value",
slice: []string{"a", "b"},
value: "c",
expected: []string{"a", "b", "c"},
},
{
name: "skip duplicate",
slice: []string{"a", "b"},
value: "a",
expected: []string{"a", "b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := appendUnique(tt.slice, tt.value)
assert.Equal(t, tt.expected, result)
})
}
}