feat: loader y executor de skills en shell
Agregar componentes impuros para manejo de skills en shell/skills/: Loader (filesystem I/O): - LoadMeta(): carga metadata de todas las skills - LoadSkill(): carga skill completa con instrucciones - ReadResource(): lee recursos con path traversal protection - Parsing de SKILL.md con frontmatter YAML Executor (script execution): - Ejecucion segura de scripts con allowlist de interpreters - Timeout obligatorio por script - Inferencia de interpreter desde extension - Proteccion contra scripts maliciosos Incluye tests completos con tmpdir para loader y executor. Arquitectura: impure shell, todo I/O aislado en shell/.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user