diff --git a/internal/recipe/runner.go b/internal/recipe/runner.go index cc008a8..e38fab5 100644 --- a/internal/recipe/runner.go +++ b/internal/recipe/runner.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -46,7 +47,16 @@ func (r *Runner) Run() error { r.handleApplyStep(refactorJSONs) continue + // === NEW: Safe shell command execution === + case strings.Contains(titleLower, "initialize") || strings.Contains(titleLower, "create") || + strings.Contains(titleLower, "run") || strings.Contains(titleLower, "shell") || + strings.Contains(titleLower, "poetry") || strings.Contains(titleLower, "git") || + strings.Contains(titleLower, "tea"): + r.executeShellCommands(step, previousResults) + continue + default: + // fallback for any other step prompt := fmt.Sprintf(`Recipe Overview: %s @@ -244,3 +254,101 @@ func createUnifiedPatch(changes []FileChange, patchPath string) error { } return nil } + +// NEW: Safe shell command execution +func (r *Runner) executeShellCommands(step Step, previousResults []string) { + prompt := fmt.Sprintf(`You are executing shell commands for this step. + +Recipe Overview: +%s + +Previous step results: +%s + +=== CURRENT STEP === +Objective: %s +Instructions: %s + +Return ONLY a JSON array of commands to run. Each command must be in this exact format: + +[ + { + "command": "poetry", + "args": ["new", "{{.project_name}}"] + } +] + +Only use commands from the allowed list. Never use cd, rm -rf, or anything that could escape the project directory.`, + r.Recipe.Overview, + strings.Join(previousResults, "\n\n---\n\n"), + step.Objective, + step.Instructions) + + messages := []map[string]string{ + {"role": "system", "content": "You are Grok, built by xAI. Precise expert programmer and refactoring assistant."}, + {"role": "user", "content": prompt}, + } + + response := r.Client.Stream(messages, r.Model) + fmt.Println() + + // Parse JSON command list + type ShellCommand struct { + Command string `json:"command"` + Args []string `json:"args"` + } + + var cmds []ShellCommand + start := strings.Index(response, "[") + end := strings.LastIndex(response, "]") + 1 + if start == -1 { + fmt.Println(" ⚠️ No valid shell commands returned — skipping.") + return + } + + if err := json.Unmarshal([]byte(response[start:end]), &cmds); err != nil { + fmt.Printf(" ⚠️ Could not parse shell commands: %v\n", err) + return + } + + // Resolve boundary + cwd := "." + if v, ok := r.Recipe.ResolvedParams["package_path"]; ok { + if s, ok := v.(string); ok && s != "" { + cwd = s + } + } + + for _, cmd := range cmds { + fullCmd := cmd.Command + if len(cmd.Args) > 0 { + fullCmd += " " + strings.Join(cmd.Args, " ") + } + + fmt.Printf(" Running: %s\n", fullCmd) + + // Whitelist check + allowed := false + for _, allowedCmd := range r.Recipe.AllowedShellCommands { + if strings.HasPrefix(cmd.Command, allowedCmd) { + allowed = true + break + } + } + if !allowed { + fmt.Printf(" ❌ Command not allowed: %s\n", cmd.Command) + continue + } + + // Execute with strict cwd + execCmd := exec.Command(cmd.Command, cmd.Args...) + execCmd.Dir = cwd + output, err := execCmd.CombinedOutput() + + if err != nil { + fmt.Printf(" ❌ Failed: %v\n%s\n", err, string(output)) + } else { + fmt.Printf(" ✅ Success\n%s\n", string(output)) + } + } +}