From 66d52917c0c91c1542d89e62b4490cb03136911d Mon Sep 17 00:00:00 2001 From: Greg Gauthier Date: Sat, 7 Mar 2026 18:50:38 +0000 Subject: [PATCH] feat(recipe): add read-only shell execution with user confirmation Implement executeReadOnlyShell method to safely run whitelisted read-only commands, prompting for user approval and integrating AI-suggested commands for filesystem context. --- internal/recipe/runner.go | 102 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/internal/recipe/runner.go b/internal/recipe/runner.go index 182a96d..b053746 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" @@ -249,3 +250,104 @@ func createUnifiedPatch(changes []FileChange, patchPath string) error { } return nil } + +// executeReadOnlyShell — safe, whitelisted, read-only shell execution with user confirmation +func (r *Runner) executeReadOnlyShell(step Step, previousResults []string) { + prompt := fmt.Sprintf(`You need additional context from the filesystem for this step. + +Recipe Overview: +%s + +Previous step results: +%s + +=== CURRENT STEP === +Objective: %s +Instructions: %s + +Return ONLY a JSON array of read-only commands. Example: + +[ + { + "command": "ls", + "args": ["-la"] + }, + { + "command": "cat", + "args": ["README.md"] + } +] + +Only use safe read-only commands from the allowed list.`, + 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() + + 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 read-only commands returned — skipping.") + return + } + + if err := json.Unmarshal([]byte(response[start:end]), &cmds); err != nil { + fmt.Printf(" ⚠️ Could not parse commands: %v\n", err) + return + } + + for _, cmd := range cmds { + fullCmd := cmd.Command + if len(cmd.Args) > 0 { + fullCmd += " " + strings.Join(cmd.Args, " ") + } + + // Safety: must be in whitelist + 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 + } + + // User confirmation + fmt.Printf(" Grok wants to run: %s\n Allow this command? [y/N] ", fullCmd) + var answer string + _, err := fmt.Scanln(&answer) + if err != nil { + return + } + if strings.HasPrefix(strings.ToLower(answer), "y") { + execCmd := exec.Command(cmd.Command, cmd.Args...) + execCmd.Dir = r.resolveWorkDir() + 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)) + previousResults = append(previousResults, fmt.Sprintf("Command output:\n%s", string(output))) + } + } else { + fmt.Println(" ❌ Cancelled by user.") + } + } +}