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

View File

@@ -15,6 +15,7 @@ Fence wraps commands in a sandbox that blocks network access by default and rest
- **Violation Monitoring**: Real-time logging of blocked requests and sandbox denials - **Violation Monitoring**: Real-time logging of blocked requests and sandbox denials
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap) - **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control - **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
- **Permission Import**: Using Claude Code? Import your Claude permissions as Fence configs with `fence import --claude -o ~/.fence.json`
You can use Fence as a Go package or CLI tool. You can use Fence as a Go package or CLI tool.
@@ -96,6 +97,9 @@ Flags:
-t, --template Use built-in template (e.g., code, local-dev-server) -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
Subcommands:
import Import settings from other tools (e.g., --claude for Claude Code)
``` ```
### Examples ### Examples
@@ -131,6 +135,9 @@ fence -m npm install
# Expose a port for inbound connections # Expose a port for inbound connections
fence -p 3000 -c "npm run dev" fence -p 3000 -c "npm run dev"
# Import settings from Claude Code
fence import --claude -o .fence.json
``` ```
## Library Usage ## Library Usage

View File

@@ -13,6 +13,7 @@ import (
"syscall" "syscall"
"github.com/Use-Tusk/fence/internal/config" "github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/importer"
"github.com/Use-Tusk/fence/internal/platform" "github.com/Use-Tusk/fence/internal/platform"
"github.com/Use-Tusk/fence/internal/sandbox" "github.com/Use-Tusk/fence/internal/sandbox"
"github.com/Use-Tusk/fence/internal/templates" "github.com/Use-Tusk/fence/internal/templates"
@@ -100,6 +101,8 @@ Configuration file format (~/.fence.json):
rootCmd.Flags().SetInterspersed(true) rootCmd.Flags().SetInterspersed(true)
rootCmd.AddCommand(newImportCmd())
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
exitCode = 1 exitCode = 1
@@ -293,6 +296,101 @@ func runCommand(cmd *cobra.Command, args []string) error {
return nil return nil
} }
// newImportCmd creates the import subcommand.
func newImportCmd() *cobra.Command {
var (
claudeMode bool
inputFile string
outputFile string
extendTmpl string
noExtend bool
)
cmd := &cobra.Command{
Use: "import",
Short: "Import settings from other tools",
Long: `Import permission settings from other tools and convert them to fence config.
Currently supported sources:
--claude Import from Claude Code settings
By default, imports extend the "code" template which provides sensible defaults
for network access (npm, GitHub, LLM providers) and filesystem protections.
Use --no-extend for a minimal config, or --extend to choose a different template.
Examples:
# Import from default Claude Code settings (~/.claude/settings.json)
fence import --claude
# Import from a specific Claude Code settings file
fence import --claude -f ~/.claude/settings.json
# Import and write to a specific output file
fence import --claude -o .fence.json
# Import without extending any template (minimal config)
fence import --claude --no-extend
# Import and extend a different template
fence import --claude --extend local-dev-server
# Import from project-level Claude settings
fence import --claude -f .claude/settings.local.json -o .fence.json`,
RunE: func(cmd *cobra.Command, args []string) error {
if !claudeMode {
return fmt.Errorf("no import source specified. Use --claude to import from Claude Code")
}
opts := importer.DefaultImportOptions()
if noExtend {
opts.Extends = ""
} else if extendTmpl != "" {
opts.Extends = extendTmpl
}
result, err := importer.ImportFromClaude(inputFile, opts)
if err != nil {
return fmt.Errorf("failed to import Claude settings: %w", err)
}
for _, warning := range result.Warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
if outputFile != "" {
if err := importer.WriteConfig(result.Config, outputFile); err != nil {
return err
}
fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
fmt.Printf("Written to %s\n", outputFile)
} else {
// Print clean JSON to stdout, helpful info to stderr (don't interfere with piping)
data, err := importer.MarshalConfigJSON(result.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
fmt.Println(string(data))
if result.Config.Extends != "" {
fmt.Fprintf(os.Stderr, "\n# Extends %q - inherited rules not shown\n", result.Config.Extends)
}
fmt.Fprintf(os.Stderr, "# Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
fmt.Fprintf(os.Stderr, "# Use -o <file> to write to a file (includes comments)\n")
}
return nil
},
}
cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings")
cmd.Flags().StringVarP(&inputFile, "file", "f", "", "Path to settings file (default: ~/.claude/settings.json for --claude)")
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path (default: stdout)")
cmd.Flags().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)")
cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)")
cmd.MarkFlagsMutuallyExclusive("extend", "no-extend")
return cmd
}
// printTemplates prints all available templates to stdout. // printTemplates prints all available templates to stdout.
func printTemplates() { func printTemplates() {
fmt.Println("Available templates:") fmt.Println("Available templates:")

View File

@@ -164,6 +164,54 @@ Fence detects blocked commands in:
|-------|-------------| |-------|-------------|
| `allowPty` | Allow pseudo-terminal (PTY) allocation in the sandbox (for MacOS) | | `allowPty` | Allow pseudo-terminal (PTY) allocation in the sandbox (for MacOS) |
## Importing from Claude Code
If you've been using Claude Code and have already built up permission rules, you can import them into fence:
```bash
# Import from default Claude Code settings (~/.claude/settings.json)
fence import --claude
# Import from a specific file
fence import --claude -f ~/.claude/settings.json
# Import and write to a specific output file
fence import --claude -o .fence.json
# Import without extending any template (minimal config)
fence import --claude --no-extend
# Import and extend a different template
fence import --claude --extend local-dev-server
# Import from project-level Claude settings
fence import --claude -f .claude/settings.local.json -o .fence.json
```
### Default Template
By default, imports extend the `code` template which provides sensible defaults:
- Network access for npm, GitHub, LLM providers, etc.
- Filesystem protections for secrets and sensitive paths
- Command restrictions for dangerous operations
Use `--no-extend` if you want a minimal config without these defaults, or `--extend <template>` to choose a different base template.
### Permission Mapping
| Claude Code | Fence |
|-------------|-------|
| `Bash(xyz)` allow | `command.allow: ["xyz"]` |
| `Bash(xyz:*)` deny | `command.deny: ["xyz"]` |
| `Read(path)` deny | `filesystem.denyRead: [path]` |
| `Write(path)` allow | `filesystem.allowWrite: [path]` |
| `Write(path)` deny | `filesystem.denyWrite: [path]` |
| `Edit(path)` | Same as `Write(path)` |
| `ask` rules | Converted to deny (fence doesn't support interactive prompts) |
Global tool permissions (e.g., bare `Read`, `Write`, `Grep`) are skipped since fence uses path/command-based rules.
## See Also ## See Also
- Config templates: [`docs/templates/`](docs/templates/) - Config templates: [`docs/templates/`](docs/templates/)

4
go.mod
View File

@@ -5,12 +5,16 @@ go 1.25
require ( require (
github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.8.4
github.com/things-go/go-socks5 v0.0.5 github.com/things-go/go-socks5 v0.0.5
github.com/tidwall/jsonc v0.3.2 github.com/tidwall/jsonc v0.3.2
golang.org/x/sys v0.39.0 golang.org/x/sys v0.39.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

1
go.sum
View File

@@ -22,6 +22,7 @@ golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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)
})
}
}