feat: ability to import claude code settings as configs (#7)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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
4
go.mod
@@ -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
1
go.sum
@@ -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
444
internal/importer/claude.go
Normal 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
|
||||||
|
}
|
||||||
581
internal/importer/claude_test.go
Normal file
581
internal/importer/claude_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user