diff --git a/internal/recipe/loader.go b/internal/recipe/loader.go index e803735..be6e225 100644 --- a/internal/recipe/loader.go +++ b/internal/recipe/loader.go @@ -4,44 +4,48 @@ import ( "bytes" "fmt" "os" + "path/filepath" "regexp" "strings" + "sync" "text/template" "gopkg.in/yaml.v3" ) // Global safe read-only whitelist -var safeCommands = map[string]bool{ - //GNU Utilities - "ls": true, - "pwd": true, - "cat": true, - "tree": true, - "find": true, - "grep": true, - "which": true, - //Git and Gitea - "git status": true, - "git log": true, - "git diff": true, - "git branch": true, - "tea repos list -o csv -lm 100": true, - "tea repos search -o csv": true, - //Miscellaneous Utilities - "cndump -s": true, - // Safe test runners - "go test": true, - "make test": true, - "make lint": true, - "pytest": true, - "poetry run pytest": true, - "ctest": true, - "python -m pytest": true, - "go vet": true, - "go fmt": true, - "go mod tidy": true, -} +// LoadSafeCommands reads the user's safe shell commands config (with fallback) +var safeCommands = sync.OnceValue(func() map[string]bool { + cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "grokkit", "safe_shell_commands.yaml") + + data, err := os.ReadFile(cfgPath) + if err != nil { + // Fallback to a built-in safe list + return map[string]bool{ + "ls": true, "pwd": true, "cat": true, "tree": true, + "find": true, "grep": true, "rg": true, + "git status": true, "git log": true, "git diff": true, "git branch": true, + "go test": true, "go vet": true, "go fmt": true, "go mod tidy": true, + "make test": true, + "pytest": true, "poetry run pytest": true, "ctest": true, + "python -m pytest": true, "python": true, "poetry": true, + } + } + + var cfg struct { + SafeCommands []string `yaml:"safe_commands"` + } + err = yaml.Unmarshal(data, &cfg) + if err != nil { + return nil + } + + m := make(map[string]bool) + for _, c := range cfg.SafeCommands { + m[strings.ToLower(strings.TrimSpace(c))] = true + } + return m +}) var ( // stepRe still finds the headings (this one is solid) @@ -64,13 +68,12 @@ func Load(path string, userParams map[string]any) (*Recipe, error) { return nil, fmt.Errorf("yaml parse: %w", err) } - // === SAFETY CHECK: reject truly dangerous commands === + // === SAFETY CHECK using user-configurable whitelist === + safeMap := safeCommands() for _, cmd := range r.AllowedShellCommands { - trimmed := strings.TrimSpace(strings.ToLower(cmd)) - - // Allow exact matches or common prefixed commands + trimmed := strings.ToLower(strings.TrimSpace(cmd)) allowed := false - for safe := range safeCommands { + for safe := range safeMap { if strings.HasPrefix(trimmed, safe) { allowed = true break diff --git a/safe_shell_commands.yaml.example b/safe_shell_commands.yaml.example new file mode 100644 index 0000000..5bd8c39 --- /dev/null +++ b/safe_shell_commands.yaml.example @@ -0,0 +1,27 @@ +# Grokkit safe shell commands whitelist +# Only commands listed here (or prefixed by these) are allowed in recipes. +# This is a safety boundary — never add rm, mv, cd, sudo, etc. +# This file should be placed in ~/.config/grokkit/safe_shell_commands.yaml +# customize it as you see fit. + +safe_commands: + - ls + - pwd + - cat + - tree + - find + - grep + - rg # ripgrep + - git status + - git log + - git diff + - git branch + - go test + - go vet + - go fmt + - go mod tidy + - make test + - pytest + - poetry run pytest + - ctest + - python -m pytest