diff --git a/shell/skills/executor.go b/shell/skills/executor.go new file mode 100644 index 0000000..fbc5e43 --- /dev/null +++ b/shell/skills/executor.go @@ -0,0 +1,110 @@ +package skills + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Executor ejecuta scripts de skills de forma segura con allowlist de interpreters. +type Executor struct { + allowedInterpreters []string + timeout time.Duration +} + +// NewExecutor crea un nuevo Executor con la configuracion dada. +// Si allowedInterpreters esta vacio, se usa un default de ["bash", "sh"]. +func NewExecutor(allowedInterpreters []string, timeout time.Duration) *Executor { + if len(allowedInterpreters) == 0 { + allowedInterpreters = []string{"bash", "sh"} + } + if timeout == 0 { + timeout = 60 * time.Second + } + return &Executor{ + allowedInterpreters: allowedInterpreters, + timeout: timeout, + } +} + +// Run ejecuta un script de skill con los argumentos dados. +// scriptPath es la ruta absoluta al script. +// args son los argumentos pasados al script. +// +// El script debe tener una extension reconocida (.sh, .bash, .py, etc.) o +// un shebang que indique el interprete. +// +// Retorna stdout+stderr combinados y error si falla. +func (e *Executor) Run(ctx context.Context, scriptPath string, args []string) (string, error) { + // Inferir interprete desde extension + interpreter, err := e.inferInterpreter(scriptPath) + if err != nil { + return "", err + } + + // Validar que el interprete esta en la allowlist + if !e.isAllowed(interpreter) { + return "", fmt.Errorf("interpreter not allowed: %s (allowed: %v)", interpreter, e.allowedInterpreters) + } + + // Construir comando + cmdArgs := append([]string{scriptPath}, args...) + cmd := exec.CommandContext(ctx, interpreter, cmdArgs...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Aplicar timeout + timeoutCtx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + + cmd = exec.CommandContext(timeoutCtx, interpreter, cmdArgs...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + output := stdout.String() + stderr.String() + + if timeoutCtx.Err() == context.DeadlineExceeded { + return output, fmt.Errorf("script timeout exceeded (%s)", e.timeout) + } + + if err != nil { + return output, fmt.Errorf("script failed: %w", err) + } + + return output, nil +} + +// inferInterpreter detecta el interprete a usar desde la extension del archivo. +func (e *Executor) inferInterpreter(path string) (string, error) { + ext := strings.ToLower(filepath.Ext(path)) + + switch ext { + case ".sh", ".bash": + return "bash", nil + case ".py": + return "python3", nil + case ".rb": + return "ruby", nil + case ".js": + return "node", nil + default: + return "", fmt.Errorf("unsupported script extension: %s", ext) + } +} + +// isAllowed verifica si un interprete esta en la allowlist. +func (e *Executor) isAllowed(interpreter string) bool { + for _, allowed := range e.allowedInterpreters { + if allowed == interpreter { + return true + } + } + return false +} diff --git a/shell/skills/executor_test.go b/shell/skills/executor_test.go new file mode 100644 index 0000000..797e499 --- /dev/null +++ b/shell/skills/executor_test.go @@ -0,0 +1,127 @@ +package skills + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestExecutor(t *testing.T) { + tmpDir := t.TempDir() + + // Create a simple bash script + scriptPath := filepath.Join(tmpDir, "test.sh") + scriptContent := `#!/bin/bash +echo "Hello from script" +echo "Args: $@" +` + if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { + t.Fatal(err) + } + + // Create a script that times out + timeoutScriptPath := filepath.Join(tmpDir, "timeout.sh") + timeoutContent := `#!/bin/bash +sleep 10 +` + if err := os.WriteFile(timeoutScriptPath, []byte(timeoutContent), 0755); err != nil { + t.Fatal(err) + } + + // Create a failing script + failScriptPath := filepath.Join(tmpDir, "fail.sh") + failContent := `#!/bin/bash +exit 1 +` + if err := os.WriteFile(failScriptPath, []byte(failContent), 0755); err != nil { + t.Fatal(err) + } + + executor := NewExecutor([]string{"bash", "sh"}, 2*time.Second) + + // Test successful execution + t.Run("successful_execution", func(t *testing.T) { + ctx := context.Background() + output, err := executor.Run(ctx, scriptPath, []string{"arg1", "arg2"}) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if !strings.Contains(output, "Hello from script") { + t.Errorf("expected 'Hello from script' in output, got: %q", output) + } + + if !strings.Contains(output, "Args: arg1 arg2") { + t.Errorf("expected 'Args: arg1 arg2' in output, got: %q", output) + } + }) + + // Test timeout + t.Run("timeout", func(t *testing.T) { + ctx := context.Background() + _, err := executor.Run(ctx, timeoutScriptPath, nil) + if err == nil { + t.Error("expected timeout error") + } + + if !strings.Contains(err.Error(), "timeout") { + t.Errorf("expected timeout error, got: %v", err) + } + }) + + // Test script failure + t.Run("script_failure", func(t *testing.T) { + ctx := context.Background() + _, err := executor.Run(ctx, failScriptPath, nil) + if err == nil { + t.Error("expected script failure error") + } + }) + + // Test disallowed interpreter + t.Run("disallowed_interpreter", func(t *testing.T) { + pyScriptPath := filepath.Join(tmpDir, "test.py") + pyContent := `#!/usr/bin/env python3 +print("hello") +` + if err := os.WriteFile(pyScriptPath, []byte(pyContent), 0755); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + _, err := executor.Run(ctx, pyScriptPath, nil) + if err == nil { + t.Error("expected error for disallowed interpreter") + } + + if !strings.Contains(err.Error(), "not allowed") { + t.Errorf("expected 'not allowed' error, got: %v", err) + } + }) + + // Test allowed python interpreter + t.Run("allowed_python", func(t *testing.T) { + pyExecutor := NewExecutor([]string{"python3"}, 2*time.Second) + + pyScriptPath := filepath.Join(tmpDir, "hello.py") + pyContent := `#!/usr/bin/env python3 +print("Hello from Python") +` + if err := os.WriteFile(pyScriptPath, []byte(pyContent), 0755); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + output, err := pyExecutor.Run(ctx, pyScriptPath, nil) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if !strings.Contains(output, "Hello from Python") { + t.Errorf("expected 'Hello from Python' in output, got: %q", output) + } + }) +} diff --git a/shell/skills/loader.go b/shell/skills/loader.go new file mode 100644 index 0000000..87db636 --- /dev/null +++ b/shell/skills/loader.go @@ -0,0 +1,223 @@ +package skills + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/enmanuel/agents/pkg/skills" + "gopkg.in/yaml.v3" +) + +// Loader descubre y carga skills desde un directorio base. +type Loader struct { + basePath string +} + +// NewLoader crea un nuevo Loader apuntando al directorio de skills. +func NewLoader(basePath string) *Loader { + return &Loader{basePath: basePath} +} + +// LoadMeta carga solo la metadata (nivel 1) de todas las skills. +// Recorre el directorio base buscando SKILL.md y extrae el frontmatter YAML. +func (l *Loader) LoadMeta() ([]skills.SkillMeta, error) { + var metas []skills.SkillMeta + + // Recorre categorias (devops/, analysis/, etc.) + categories, err := os.ReadDir(l.basePath) + if err != nil { + return nil, fmt.Errorf("read skills dir: %w", err) + } + + for _, catEntry := range categories { + if !catEntry.IsDir() { + continue + } + + category := catEntry.Name() + catPath := filepath.Join(l.basePath, category) + + // Recorre skills dentro de la categoria + skillDirs, err := os.ReadDir(catPath) + if err != nil { + continue + } + + for _, skillEntry := range skillDirs { + if !skillEntry.IsDir() { + continue + } + + skillName := skillEntry.Name() + skillPath := filepath.Join(catPath, skillName) + skillMdPath := filepath.Join(skillPath, "SKILL.md") + + // Verificar que existe SKILL.md + if _, err := os.Stat(skillMdPath); os.IsNotExist(err) { + continue + } + + // Parsear metadata + meta, _, err := parseSkillMD(skillMdPath) + if err != nil { + continue // skip invalid skills + } + + meta.Category = category + metas = append(metas, meta) + } + } + + return metas, nil +} + +// LoadSkill carga una skill completa (nivel 2) por nombre. +// Retorna el struct Skill con metadata, instrucciones y listado de recursos. +func (l *Loader) LoadSkill(name string) (*skills.Skill, error) { + // Buscar en todas las categorias + categories, err := os.ReadDir(l.basePath) + if err != nil { + return nil, fmt.Errorf("read skills dir: %w", err) + } + + for _, catEntry := range categories { + if !catEntry.IsDir() { + continue + } + + category := catEntry.Name() + skillPath := filepath.Join(l.basePath, category, name) + skillMdPath := filepath.Join(skillPath, "SKILL.md") + + if _, err := os.Stat(skillMdPath); os.IsNotExist(err) { + continue + } + + // Parsear skill completa + meta, instructions, err := parseSkillMD(skillMdPath) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", skillMdPath, err) + } + + meta.Category = category + + skill := &skills.Skill{ + Meta: meta, + Instructions: instructions, + BasePath: skillPath, + Scripts: listFiles(filepath.Join(skillPath, "scripts")), + References: listFiles(filepath.Join(skillPath, "references")), + Templates: listFiles(filepath.Join(skillPath, "templates")), + Assets: listFiles(filepath.Join(skillPath, "assets")), + } + + return skill, nil + } + + return nil, fmt.Errorf("skill not found: %s", name) +} + +// ReadResource lee un recurso especifico (nivel 3) de una skill. +// path es relativo a la skill (ej: "scripts/deploy.sh", "references/api.md"). +func (l *Loader) ReadResource(skillName, resourcePath string) (string, error) { + skill, err := l.LoadSkill(skillName) + if err != nil { + return "", err + } + + fullPath := filepath.Join(skill.BasePath, resourcePath) + + // Validar que el path esta dentro de la skill (evitar path traversal) + absBasePath, err := filepath.Abs(skill.BasePath) + if err != nil { + return "", fmt.Errorf("abs base path: %w", err) + } + + absFullPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("abs resource path: %w", err) + } + + if !strings.HasPrefix(absFullPath, absBasePath) { + return "", fmt.Errorf("path traversal detected: %s", resourcePath) + } + + content, err := os.ReadFile(absFullPath) + if err != nil { + return "", fmt.Errorf("read resource: %w", err) + } + + return string(content), nil +} + +// parseSkillMD extrae el frontmatter YAML y el cuerpo markdown de un SKILL.md. +func parseSkillMD(path string) (skills.SkillMeta, string, error) { + f, err := os.Open(path) + if err != nil { + return skills.SkillMeta{}, "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var yamlLines []string + var bodyLines []string + inYAML := false + yamlClosed := false + + for scanner.Scan() { + line := scanner.Text() + + if strings.TrimSpace(line) == "---" { + if !inYAML { + inYAML = true + continue + } else { + inYAML = false + yamlClosed = true + continue + } + } + + if inYAML { + yamlLines = append(yamlLines, line) + } else if yamlClosed { + bodyLines = append(bodyLines, line) + } + } + + if err := scanner.Err(); err != nil { + return skills.SkillMeta{}, "", err + } + + // Parse YAML frontmatter + var meta skills.SkillMeta + yamlStr := strings.Join(yamlLines, "\n") + if err := yaml.Unmarshal([]byte(yamlStr), &meta); err != nil { + return skills.SkillMeta{}, "", fmt.Errorf("parse yaml: %w", err) + } + + // Cuerpo markdown + body := strings.Join(bodyLines, "\n") + + return meta, body, nil +} + +// listFiles retorna una lista de archivos (rutas relativas) dentro de un directorio. +// Si el directorio no existe, retorna una lista vacia. +func listFiles(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + return files +} diff --git a/shell/skills/loader_test.go b/shell/skills/loader_test.go new file mode 100644 index 0000000..9564654 --- /dev/null +++ b/shell/skills/loader_test.go @@ -0,0 +1,131 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoader(t *testing.T) { + // Create temporary skills directory structure + tmpDir := t.TempDir() + + // Create a test skill + skillDir := filepath.Join(tmpDir, "devops", "test-skill") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatal(err) + } + + // Write SKILL.md + skillMD := `--- +name: test-skill +description: A test skill for unit testing +--- + +# Test Skill + +This is the instructions body. +It has multiple lines. +` + skillMDPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillMDPath, []byte(skillMD), 0644); err != nil { + t.Fatal(err) + } + + // Create scripts/ directory with a test script + scriptsDir := filepath.Join(skillDir, "scripts") + if err := os.MkdirAll(scriptsDir, 0755); err != nil { + t.Fatal(err) + } + scriptPath := filepath.Join(scriptsDir, "test.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\necho test"), 0755); err != nil { + t.Fatal(err) + } + + // Create references/ directory with a test reference + refsDir := filepath.Join(skillDir, "references") + if err := os.MkdirAll(refsDir, 0755); err != nil { + t.Fatal(err) + } + refPath := filepath.Join(refsDir, "api.md") + if err := os.WriteFile(refPath, []byte("# API Reference"), 0644); err != nil { + t.Fatal(err) + } + + loader := NewLoader(tmpDir) + + // Test LoadMeta + t.Run("LoadMeta", func(t *testing.T) { + metas, err := loader.LoadMeta() + if err != nil { + t.Fatalf("LoadMeta failed: %v", err) + } + + if len(metas) != 1 { + t.Fatalf("expected 1 skill, got %d", len(metas)) + } + + meta := metas[0] + if meta.Name != "test-skill" { + t.Errorf("expected name 'test-skill', got %q", meta.Name) + } + if meta.Category != "devops" { + t.Errorf("expected category 'devops', got %q", meta.Category) + } + if meta.Description != "A test skill for unit testing" { + t.Errorf("expected description 'A test skill for unit testing', got %q", meta.Description) + } + }) + + // Test LoadSkill + t.Run("LoadSkill", func(t *testing.T) { + skill, err := loader.LoadSkill("test-skill") + if err != nil { + t.Fatalf("LoadSkill failed: %v", err) + } + + if skill.Meta.Name != "test-skill" { + t.Errorf("expected name 'test-skill', got %q", skill.Meta.Name) + } + + if skill.Instructions == "" { + t.Error("instructions should not be empty") + } + + if len(skill.Scripts) != 1 || skill.Scripts[0] != "test.sh" { + t.Errorf("expected Scripts=['test.sh'], got %v", skill.Scripts) + } + + if len(skill.References) != 1 || skill.References[0] != "api.md" { + t.Errorf("expected References=['api.md'], got %v", skill.References) + } + }) + + // Test LoadSkill nonexistent + t.Run("LoadSkill_nonexistent", func(t *testing.T) { + _, err := loader.LoadSkill("nonexistent") + if err == nil { + t.Error("expected error for nonexistent skill") + } + }) + + // Test ReadResource + t.Run("ReadResource", func(t *testing.T) { + content, err := loader.ReadResource("test-skill", "scripts/test.sh") + if err != nil { + t.Fatalf("ReadResource failed: %v", err) + } + + if content != "#!/bin/bash\necho test" { + t.Errorf("unexpected content: %q", content) + } + }) + + // Test ReadResource path traversal protection + t.Run("ReadResource_path_traversal", func(t *testing.T) { + _, err := loader.ReadResource("test-skill", "../../../etc/passwd") + if err == nil { + t.Error("expected error for path traversal attempt") + } + }) +}