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.
147 lines
3.9 KiB
Go
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
|
|
}
|