package recipe import ( "bytes" "fmt" "os" "path/filepath" "regexp" "strings" "sync" "text/template" "gopkg.in/yaml.v3" ) // Global safe read-only whitelist // 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 fmt.Println("Could not read safe shell commands config, using built-in fallback") 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, } } fmt.Println("Using safe shell commands config:", cfgPath) 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) stepRe = regexp.MustCompile(`(?m)^### Step (\d+): (.+)$`) ) func Load(path string, userParams map[string]any) (*Recipe, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } parts := bytes.SplitN(b, []byte("---"), 3) if len(parts) < 3 { return nil, fmt.Errorf("missing YAML frontmatter") } var r Recipe if err := yaml.Unmarshal(parts[1], &r); err != nil { return nil, fmt.Errorf("yaml parse: %w", err) } // === SAFETY CHECK using user-configurable whitelist === safeMap := safeCommands() for _, cmd := range r.AllowedShellCommands { trimmed := strings.ToLower(strings.TrimSpace(cmd)) allowed := false for safe := range safeMap { safeTrim := strings.ToLower(strings.TrimSpace(safe)) // Match exact command OR command followed by space + arguments if trimmed == safeTrim || strings.HasPrefix(trimmed, safeTrim+" ") { allowed = true break } } if !allowed { return nil, fmt.Errorf("\u001B[31mRecipe contains unsafe shell command: %q. "+ "Remove or replace the dangerous command in your recipe.\u001B[0m", cmd) } } // Apply defaults + user --param overrides if r.Parameters == nil { r.Parameters = make(map[string]Parameter) } r.ResolvedParams = make(map[string]any) for name, p := range r.Parameters { if v, ok := userParams[name]; ok { r.ResolvedParams[name] = v } else if p.Default != nil { r.ResolvedParams[name] = p.Default } } // Render templates with resolved values tpl, err := template.New("recipe").Parse(string(parts[2])) if err != nil { return nil, err } var rendered bytes.Buffer if err := tpl.Execute(&rendered, r.ResolvedParams); err != nil { return nil, err } body := rendered.String() // Extract Overview if idx := strings.Index(body, "## Execution Steps"); idx != -1 { r.Overview = strings.TrimSpace(body[:idx]) } // Extract steps with robust multi-line parsing matches := stepRe.FindAllStringSubmatch(body, -1) for i, m := range matches { stepNum := i + 1 title := m[2] start := strings.Index(body, m[0]) end := len(body) if i+1 < len(matches) { nextStart := strings.Index(body[start:], matches[i+1][0]) end = start + nextStart } section := body[start:end] step := Step{Number: stepNum, Title: title} // Simple, reliable label-based parsing (handles multi-line + blank lines) labels := []string{"**Objective:**", "**Instructions:**", "**Expected output:**"} for _, label := range labels { labelStart := strings.Index(section, label) if labelStart == -1 { continue } contentStart := labelStart + len(label) contentEnd := len(section) // Find next label or end of section for _, nextLabel := range labels { next := strings.Index(section[contentStart:], nextLabel) if next != -1 { contentEnd = contentStart + next break } } content := strings.TrimSpace(section[contentStart:contentEnd]) switch label { case "**Objective:**": step.Objective = content case "**Instructions:**": step.Instructions = content case "**Expected output:**": step.Expected = content } } r.Steps = append(r.Steps, step) } // Final summary (everything after last step) if len(matches) > 0 { lastMatch := matches[len(matches)-1][0] lastIdx := strings.LastIndex(body, lastMatch) r.FinalSummaryPrompt = strings.TrimSpace(body[lastIdx+len(lastMatch):]) } return &r, nil }