grokkit/internal/recipe/runner.go
Greg Gauthier 0912aa7b89 feat(recipe): add safe shell command execution
Introduce a new mechanism in the recipe runner to execute whitelisted shell commands for steps like initialization, creation, or running tools (e.g., poetry, git). Commands are generated via AI prompt, parsed from JSON, validated against an allowed list, and executed within a strict working directory to ensure safety.
2026-03-07 15:34:50 +00:00

355 lines
8.8 KiB
Go

package recipe
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/internal/grok"
)
type Runner struct {
Recipe *Recipe
Client *grok.Client
Model string
}
func NewRunner(r *Recipe, client *grok.Client, model string) *Runner {
return &Runner{Recipe: r, Client: client, Model: model}
}
func (r *Runner) Run() error {
fmt.Printf("🍳 Starting recipe: %s v%s\n\n", r.Recipe.Name, r.Recipe.Version)
var previousResults []string
var refactorJSONs []string
for _, step := range r.Recipe.Steps {
fmt.Printf("Step %d/%d: %s\n", step.Number, len(r.Recipe.Steps), step.Title)
titleLower := strings.ToLower(step.Title)
switch {
case strings.Contains(titleLower, "discover") || strings.Contains(titleLower, "find"):
files := r.discoverFiles()
result := strings.Join(files, "\n")
previousResults = append(previousResults, "Discovered files:\n"+result)
fmt.Println(result)
case strings.Contains(titleLower, "refactor"):
r.refactorFiles(previousResults, &refactorJSONs)
continue
case strings.Contains(titleLower, "apply") || strings.Contains(titleLower, "patch"):
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
Previous step results (for context):
%s
=== CURRENT STEP ===
Objective: %s
Instructions: %s
Expected output format: %s
Execute this step now. Respond ONLY with the expected output format — no explanations, no extra text.`,
r.Recipe.Overview,
strings.Join(previousResults, "\n\n---\n\n"),
step.Objective,
step.Instructions,
step.Expected)
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()
previousResults = append(previousResults, fmt.Sprintf("Step %d result:\n%s", step.Number, response))
}
}
fmt.Println("\n✅ Recipe complete.")
return nil
}
// discoverFiles — fully generic using recipe metadata
func (r *Runner) discoverFiles() []string {
var files []string
// 1. Use explicit --param package_path if provided
root := "."
if v, ok := r.Recipe.ResolvedParams["package_path"]; ok {
if s, ok := v.(string); ok && s != "" {
root = s
}
}
// 2. Smart defaults if no param was given
if root == "." {
if _, err := os.Stat("src"); err == nil {
root = "src"
}
}
// 3. Build allowed extensions from recipe frontmatter
allowedExt := make(map[string]bool)
for _, lang := range r.Recipe.ProjectLanguages {
if exts, ok := r.Recipe.Extensions[lang]; ok {
for _, ext := range exts {
allowedExt[ext] = true
}
}
}
// 4. Use a configurable search pattern (defaults to Go-style)
searchFor := r.Recipe.SearchPattern
if searchFor == "" {
searchFor = "if err != nil"
}
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if allowedExt[filepath.Ext(path)] {
b, _ := os.ReadFile(path)
if strings.Contains(string(b), searchFor) {
files = append(files, path)
}
}
return nil
})
if len(files) == 0 {
files = append(files, "No files found matching the criteria.")
}
return files
}
// refactorFiles — one file at a time
func (r *Runner) refactorFiles(previousResults []string, refactorJSONs *[]string) {
discoveredLine := previousResults[len(previousResults)-1]
lines := strings.Split(discoveredLine, "\n")
for _, line := range lines {
filePath := strings.TrimSpace(line)
if filePath == "" || strings.HasPrefix(filePath, "Discovered") || filePath == "No files found matching the criteria." {
continue
}
fmt.Printf(" Refactoring %s...\n", filePath)
content, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf(" ❌ Could not read %s\n", filePath)
continue
}
prompt := fmt.Sprintf(`Refactor the following file to use Result[T] instead of naked errors.
Follow existing style and preserve all comments.
Return ONLY this exact JSON (no extra text, no markdown):
{
"file": "%s",
"content": "the complete refactored file here"
}
Original file:
%s`, filePath, string(content))
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()
*refactorJSONs = append(*refactorJSONs, response)
}
}
type FileChange struct {
File string `json:"file"`
Content string `json:"content"`
}
func (r *Runner) handleApplyStep(refactorJSONs []string) {
if len(refactorJSONs) == 0 {
fmt.Println(" ⚠️ No refactored files to apply — skipping.")
return
}
var allChanges []FileChange
for _, jsonStr := range refactorJSONs {
start := strings.Index(jsonStr, "{")
end := strings.LastIndex(jsonStr, "}") + 1
if start == -1 {
continue
}
var ch FileChange
if err := json.Unmarshal([]byte(jsonStr[start:end]), &ch); err == nil && ch.File != "" {
allChanges = append(allChanges, ch)
}
}
if len(allChanges) == 0 {
fmt.Println(" ⚠️ No valid file changes found — skipping.")
return
}
fmt.Println(" 📄 Dry-run mode: creating patch file...")
patchPath := filepath.Join(".", "recipe-refactor.patch")
if err := createUnifiedPatch(allChanges, patchPath); err != nil {
fmt.Printf(" ❌ Failed to create patch: %v\n", err)
return
}
fmt.Printf(" ✅ Patch created: %s\n", patchPath)
fmt.Println(" Review it, then run with dry_run=false to apply.")
}
func createUnifiedPatch(changes []FileChange, patchPath string) error {
f, err := os.Create(patchPath)
if err != nil {
return err
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
return
}
}(f)
for _, ch := range changes {
_, err := fmt.Fprintf(f, "--- %s\n+++ %s\n@@ -0,0 +1,%d @@\n", ch.File, ch.File, strings.Count(ch.Content, "\n")+1)
if err != nil {
return err
}
for _, line := range strings.Split(ch.Content, "\n") {
_, err := fmt.Fprintf(f, "+%s\n", line)
if err != nil {
return err
}
}
}
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))
}
}
}