Update the command matching logic to require an exact match or the command followed by a space and arguments. Also normalize case and trim whitespace for safe commands to prevent loose prefix matches that could allow unintended commands.
184 lines
4.7 KiB
Go
184 lines
4.7 KiB
Go
package recipe
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Global safe read-only whitelist
|
|
// LoadSafeCommands reads the user's safe shell commands config (with fallback)
|
|
var safeCommands = sync.OnceValue(func() map[string]bool {
|
|
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "grokkit", "safe_shell_commands.yaml")
|
|
|
|
data, err := os.ReadFile(cfgPath)
|
|
if err != nil {
|
|
// Fallback to a built-in safe list
|
|
fmt.Println("Could not read safe shell commands config, using built-in fallback")
|
|
return map[string]bool{
|
|
"ls": true, "pwd": true, "cat": true, "tree": true,
|
|
"find": true, "grep": true, "rg": true,
|
|
"git status": true, "git log": true, "git diff": true, "git branch": true,
|
|
"go test": true, "go vet": true, "go fmt": true, "go mod tidy": true,
|
|
"make test": true,
|
|
"pytest": true, "poetry run pytest": true, "ctest": true,
|
|
"python -m pytest": true, "python": true, "poetry": true,
|
|
}
|
|
|
|
}
|
|
fmt.Println("Using safe shell commands config:", cfgPath)
|
|
|
|
var cfg struct {
|
|
SafeCommands []string `yaml:"safe_commands"`
|
|
}
|
|
err = yaml.Unmarshal(data, &cfg)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
m := make(map[string]bool)
|
|
for _, c := range cfg.SafeCommands {
|
|
m[strings.ToLower(strings.TrimSpace(c))] = true
|
|
}
|
|
return m
|
|
})
|
|
|
|
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 using user-configurable whitelist ===
|
|
safeMap := safeCommands()
|
|
for _, cmd := range r.AllowedShellCommands {
|
|
trimmed := strings.ToLower(strings.TrimSpace(cmd))
|
|
|
|
allowed := false
|
|
for safe := range safeMap {
|
|
safeTrim := strings.ToLower(strings.TrimSpace(safe))
|
|
|
|
// Match exact command OR command followed by space + arguments
|
|
if trimmed == safeTrim || strings.HasPrefix(trimmed, safeTrim+" ") {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
return nil, fmt.Errorf("\u001B[31mRecipe contains unsafe shell command: %q. "+
|
|
"Remove or replace the dangerous command in your recipe.\u001B[0m", 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
|
|
}
|