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:
2026-03-08 22:13:12 +00:00
parent f4690a2ba6
commit 3638529468
4 changed files with 591 additions and 0 deletions
+110
View File
@@ -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
}
+127
View File
@@ -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)
}
})
}
+223
View File
@@ -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
}
+131
View File
@@ -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")
}
})
}