Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ cage [flags] <command> [args...]
- `-allow-keychain`: Allow write access to the macOS keychain (macOS only)
- `-allow-git`: Allow access to git common directory (enables git operations in worktrees)
- `-allow-all`: Disable all restrictions (useful for debugging)
- `-preset <name>`: Use a predefined preset configuration
- `-preset <name>`: Use a predefined preset configuration (can be used multiple times)
- `-list-presets`: List available presets
- `-config <path>`: Path to custom configuration file

Expand Down Expand Up @@ -113,6 +113,11 @@ cage -list-presets

# Use custom configuration file
cage -config $HOME/my-presets.yaml -preset custom-preset ./script.sh

# Auto-presets in action (when configured)
cage claude help # Automatically applies claude-code preset
cage npm install # Automatically applies npm preset
cage yarn build # Automatically applies npm preset (via regex pattern)
```

### Configuration File
Expand Down Expand Up @@ -165,6 +170,52 @@ Presets support the following options:
- `allow-git`: Enable access to git common directory (boolean)
- `allow-keychain`: Enable macOS keychain access (boolean)

#### Auto-Presets

Cage can automatically apply presets based on the command being executed. This feature helps reduce typing and ensures consistent permissions for common tools.

Example auto-presets configuration:

```yaml
presets:
claude-code:
allow:
- "$HOME/.config/claude"
- "$HOME/tmp"
- "/tmp"
allow-keychain: true

npm:
allow:
- "."
- "$HOME/.npm"

auto-presets:
# Exact command match
- command: claude
presets:
- claude-code
- tmp

# Regex pattern match
- command-pattern: ^(npm|npx|yarn)$
presets:
- npm

# Multiple presets can be applied
- command: git
presets:
- git-enabled
- tmp
```

Auto-preset rules support:
- `command`: Exact command name match (basename of the command)
- `command-pattern`: Regular expression pattern to match command names
- `presets`: List of preset names to apply

**Note**: Auto-presets are merged with explicit `--preset` flags. Command-line presets are processed first, maintaining their priority over auto-presets.

## Platform Implementation

### Linux
Expand Down
48 changes: 47 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"

"github.com/goccy/go-yaml"
)

type Config struct {
Presets map[string]Preset `yaml:"presets"`
Presets map[string]Preset `yaml:"presets"`
AutoPresets []AutoPresetRule `yaml:"auto-presets"`
}

type Preset struct {
Expand All @@ -21,6 +23,12 @@ type Preset struct {
AllowGit bool `yaml:"allow-git"`
}

type AutoPresetRule struct {
Command string `yaml:"command,omitempty"`
CommandPattern string `yaml:"command-pattern,omitempty"`
Presets []string `yaml:"presets"`
}

func userConfigDir() (string, error) {
// os.UserConfigDir() does not respect XDG_CONFIG_HOME on darwin.
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
Expand Down Expand Up @@ -88,6 +96,44 @@ func (c *Config) ListPresets() []string {
return presets
}

// GetAutoPresets returns the preset names that should be automatically applied for the given command
func (c *Config) GetAutoPresets(command string) ([]string, error) {
var presets []string

// Extract just the base command name from the full path
baseCommand := filepath.Base(command)

for _, rule := range c.AutoPresets {
matched := false

// Check exact command match
if rule.Command != "" && rule.Command == baseCommand {
matched = true
}

// Check regex pattern match
if !matched && rule.CommandPattern != "" {
re, err := regexp.Compile(rule.CommandPattern)
if err != nil {
return nil, fmt.Errorf(
"invalid regex pattern in auto-preset: %s: %w",
rule.CommandPattern,
err,
)
}
if re.MatchString(baseCommand) {
matched = true
}
}

if matched {
presets = append(presets, rule.Presets...)
}
}

return presets, nil
}

// expandEnvOnly expands environment variables in a path
// This is safer than shell expansion as it doesn't allow command execution
func expandEnvOnly(path string) string {
Expand Down
194 changes: 194 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,197 @@ func TestProcessPresetWithAllowGit(t *testing.T) {
}
}
}

func TestGetAutoPresets(t *testing.T) {
config := &Config{
Presets: map[string]Preset{
"claude-code": {Allow: []string{"/tmp"}},
"npm": {Allow: []string{"~/.npm"}},
"python": {Allow: []string{"~/.python"}},
},
AutoPresets: []AutoPresetRule{
{
Command: "claude",
Presets: []string{"claude-code"},
},
{
CommandPattern: "^(npm|npx|yarn)$",
Presets: []string{"npm"},
},
{
Command: "python",
Presets: []string{"python"},
},
{
CommandPattern: "^python[0-9]+$",
Presets: []string{"python"},
},
},
}

tests := []struct {
name string
command string
wantPresets []string
wantErr bool
}{
{
name: "exact command match",
command: "claude",
wantPresets: []string{"claude-code"},
wantErr: false,
},
{
name: "exact command match with path",
command: "/usr/bin/claude",
wantPresets: []string{"claude-code"},
wantErr: false,
},
{
name: "regex pattern match npm",
command: "npm",
wantPresets: []string{"npm"},
wantErr: false,
},
{
name: "regex pattern match npx",
command: "npx",
wantPresets: []string{"npm"},
wantErr: false,
},
{
name: "regex pattern match yarn",
command: "/usr/local/bin/yarn",
wantPresets: []string{"npm"},
wantErr: false,
},
{
name: "both exact and pattern match",
command: "python",
wantPresets: []string{"python"},
wantErr: false,
},
{
name: "pattern match python3",
command: "python3",
wantPresets: []string{"python"},
wantErr: false,
},
{
name: "no match",
command: "ls",
wantPresets: []string{},
wantErr: false,
},
{
name: "no match with path",
command: "/bin/ls",
wantPresets: []string{},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
presets, err := config.GetAutoPresets(tt.command)
if (err != nil) != tt.wantErr {
t.Errorf("GetAutoPresets() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !tt.wantErr {
if len(presets) != len(tt.wantPresets) {
t.Errorf(
"GetAutoPresets() returned %d presets, want %d",
len(presets),
len(tt.wantPresets),
)
return
}

for i, got := range presets {
if got != tt.wantPresets[i] {
t.Errorf(
"GetAutoPresets() preset[%d] = %v, want %v",
i,
got,
tt.wantPresets[i],
)
}
}
}
})
}
}

func TestGetAutoPresetsInvalidRegex(t *testing.T) {
config := &Config{
AutoPresets: []AutoPresetRule{
{
CommandPattern: "[invalid regex",
Presets: []string{"test"},
},
},
}

_, err := config.GetAutoPresets("test")
if err == nil {
t.Error("expected error for invalid regex pattern")
}
}

func TestLoadConfigWithAutoPresets(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.yaml")
content := `presets:
claude-code:
allow:
- "/tmp"
npm:
allow:
- "~/.npm"

auto-presets:
- command: claude
presets:
- claude-code
- command-pattern: ^(npm|npx)$
presets:
- npm`
os.WriteFile(configPath, []byte(content), 0o644)

config, err := loadConfig(configPath)
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}

// Check presets loaded correctly
if len(config.Presets) != 2 {
t.Errorf("expected 2 presets, got %d", len(config.Presets))
}

// Check auto-presets loaded correctly
if len(config.AutoPresets) != 2 {
t.Errorf("expected 2 auto-preset rules, got %d", len(config.AutoPresets))
}

// Check first auto-preset rule
if config.AutoPresets[0].Command != "claude" {
t.Errorf(
"expected first rule command to be 'claude', got %s",
config.AutoPresets[0].Command,
)
}
if len(config.AutoPresets[0].Presets) != 1 ||
config.AutoPresets[0].Presets[0] != "claude-code" {
t.Errorf("unexpected presets for first rule: %v", config.AutoPresets[0].Presets)
}

// Check second auto-preset rule
if config.AutoPresets[1].CommandPattern != "^(npm|npx)$" {
t.Errorf(
"expected second rule pattern to be '^(npm|npx)$', got %s",
config.AutoPresets[1].CommandPattern,
)
}
}
13 changes: 13 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ func main() {
os.Exit(1)
}

// Auto-detect presets and merge with command-line presets
if len(config.AutoPresets) > 0 {
autoPresets, err := config.GetAutoPresets(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "cage: error detecting auto-presets: %v\n", err)
os.Exit(1)
}

// Merge auto-detected presets with command-line presets
// Command-line presets come first to maintain priority
flags.presets = append(flags.presets, autoPresets...)
}

// Merge preset paths with command-line paths
allowedPaths := flags.allowPaths
allowKeychain := flags.allowKeychain
Expand Down
Loading