grokkit/internal/recipe/loader.go
Greg Gauthier 63e640c022 feat(recipe): add safety whitelist for allowed shell commands
Implement a read-only command whitelist in the recipe loader to reject
potentially dangerous shell commands, ensuring only safe operations like
ls, pwd, cat, etc., are permitted. This enhances security by preventing
execution of unauthorized commands in recipes.
2026-03-07 17:11:13 +00:00

147 lines
3.9 KiB
Go

package recipe
import (
"bytes"
"fmt"
"os"
"regexp"
"strings"
"text/template"
"gopkg.in/yaml.v3"
)
// Global safe read-only whitelist
var safeReadOnlyCommands = map[string]bool{
"ls": true,
"pwd": true,
"cat": true,
"tree": true,
"git status": true,
"git log": true,
"find": true,
"grep": true,
"cndump -s": true,
"tea repos list -o csv -lm 100": true,
"tea repos search -o csv": true,
}
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: reject dangerous allowed_shell_commands ===
for _, cmd := range r.AllowedShellCommands {
trimmed := strings.TrimSpace(strings.ToLower(cmd))
if !safeReadOnlyCommands[trimmed] && !strings.HasPrefix(trimmed, "git status") && !strings.HasPrefix(trimmed, "git log") {
return nil, fmt.Errorf("\033[31mERROR: Recipe contains unsafe shell command: %q\033[0m\n\nOnly the following read-only commands are allowed:\n ls, pwd, cat, tree, git status, git log, find, grep\n\nRemove or replace the dangerous command and try again.", 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
}