270 lines
8.4 KiB
Go
270 lines
8.4 KiB
Go
// Package project gestiona PROJECT_ROOT: localiza agent.md, descubre las
|
|
// tareas autorizadas y prepara los archivos y directorios internos.
|
|
//
|
|
// Seguridad: este paquete implementa la regla de aislamiento de
|
|
// instrucciones. Solo los archivos ubicados DIRECTAMENTE en PROJECT_ROOT
|
|
// (sin recursión y sin symlinks que apunten fuera) pueden convertirse en
|
|
// instrucciones. Cualquier otro archivo es un dato, nunca una orden.
|
|
package project
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Nombres de archivos especiales dentro de PROJECT_ROOT.
|
|
const (
|
|
AgentFile = "agent.md"
|
|
MemoryFile = "memoria.md"
|
|
ContextFile = "contexto.mem"
|
|
ReadmeFile = "README.md"
|
|
AuditFile = "agent.log"
|
|
HistoryFile = ".context-history"
|
|
WorkDir = ".agent"
|
|
)
|
|
|
|
// ErrAgentNotFound se devuelve cuando PROJECT_ROOT no contiene agent.md.
|
|
var ErrAgentNotFound = errors.New("agent.md not found in PROJECT_ROOT")
|
|
|
|
// Project representa el directorio raíz del agente.
|
|
type Project struct {
|
|
// Root es la ruta absoluta desde la que se inició el programa.
|
|
Root string
|
|
// realRoot es Root con los symlinks resueltos; se usa para comprobar
|
|
// que un archivo está realmente dentro del proyecto.
|
|
realRoot string
|
|
}
|
|
|
|
// Task es un archivo de tarea autorizado.
|
|
type Task struct {
|
|
Name string // nombre base, p.ej. "01-auditar-ssh.md"
|
|
Path string // ruta absoluta
|
|
Content string
|
|
}
|
|
|
|
// Open crea un Project a partir de un directorio (normalmente os.Getwd()).
|
|
func Open(dir string) (*Project, error) {
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve project root %s: %w", dir, err)
|
|
}
|
|
real, err := filepath.EvalSymlinks(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve project root %s: %w", abs, err)
|
|
}
|
|
return &Project{Root: abs, realRoot: real}, nil
|
|
}
|
|
|
|
// Path devuelve la ruta absoluta de un archivo dentro de PROJECT_ROOT.
|
|
func (p *Project) Path(name string) string {
|
|
return filepath.Join(p.Root, name)
|
|
}
|
|
|
|
// FindAgentFile localiza agent.md.
|
|
//
|
|
// Se prefiere el nombre exacto "agent.md". Si no existe, se acepta una única
|
|
// variante que solo difiera en mayúsculas (p.ej. "Agent.md"), porque es un
|
|
// error común y el archivo sigue estando dentro del proyecto. Si hay varias
|
|
// variantes, se rechaza: no se puede saber cuál es la autoridad.
|
|
//
|
|
// Devuelve el nombre base encontrado y si hubo que usar una variante.
|
|
func (p *Project) FindAgentFile() (name string, variant bool, err error) {
|
|
if ok, err := p.isInstructionFile(AgentFile); err != nil {
|
|
return "", false, err
|
|
} else if ok {
|
|
return AgentFile, false, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(p.Root)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("failed to list %s: %w", p.Root, err)
|
|
}
|
|
var matches []string
|
|
for _, e := range entries {
|
|
if strings.EqualFold(e.Name(), AgentFile) {
|
|
matches = append(matches, e.Name())
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return "", false, ErrAgentNotFound
|
|
case 1:
|
|
ok, err := p.isInstructionFile(matches[0])
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if !ok {
|
|
return "", false, ErrAgentNotFound
|
|
}
|
|
return matches[0], true, nil
|
|
default:
|
|
return "", false, fmt.Errorf("ambiguous agent file: found %s; keep only one", strings.Join(matches, ", "))
|
|
}
|
|
}
|
|
|
|
// ReadInstructionFile lee un archivo de instrucciones verificando que se
|
|
// encuentre realmente dentro de PROJECT_ROOT.
|
|
func (p *Project) ReadInstructionFile(name string) (string, error) {
|
|
ok, err := p.isInstructionFile(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !ok {
|
|
return "", fmt.Errorf("%s is not a regular file directly inside %s", name, p.Root)
|
|
}
|
|
if st, err := os.Stat(p.Path(name)); err == nil && st.Size() > MaxInstructionSize {
|
|
return "", fmt.Errorf("%s is %d bytes; instruction files are limited to %d bytes", p.Path(name), st.Size(), MaxInstructionSize)
|
|
}
|
|
data, err := os.ReadFile(p.Path(name))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read %s: %w", p.Path(name), err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// DiscoverTasks devuelve las tareas *.md de PROJECT_ROOT.
|
|
//
|
|
// Excluye agent.md (y su variante encontrada), memoria.md y README.md,
|
|
// sin distinguir mayúsculas. No es recursivo. Los symlinks que resuelven
|
|
// fuera del proyecto se omiten y se reportan en skipped.
|
|
//
|
|
// Orden: si order no está vacío (TASK_ORDER de agent.md) se procesan primero
|
|
// esas tareas en ese orden y después el resto en orden lexicográfico.
|
|
// Los nombres de order que no existan se reportan en missing.
|
|
func (p *Project) DiscoverTasks(agentName string, order []string) (tasks []Task, skipped, missing []string, err error) {
|
|
entries, err := os.ReadDir(p.Root)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to list %s: %w", p.Root, err)
|
|
}
|
|
excluded := map[string]bool{
|
|
strings.ToLower(AgentFile): true,
|
|
strings.ToLower(agentName): true,
|
|
strings.ToLower(MemoryFile): true,
|
|
strings.ToLower(ReadmeFile): true,
|
|
}
|
|
|
|
byName := map[string]Task{}
|
|
var names []string
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.EqualFold(filepath.Ext(name), ".md") || excluded[strings.ToLower(name)] {
|
|
continue
|
|
}
|
|
ok, err := p.isInstructionFile(name)
|
|
if err != nil || !ok {
|
|
skipped = append(skipped, name)
|
|
continue
|
|
}
|
|
if st, err := os.Stat(p.Path(name)); err == nil && st.Size() > MaxInstructionSize {
|
|
skipped = append(skipped, name+" (larger than 1 MiB)")
|
|
continue
|
|
}
|
|
content, err := os.ReadFile(p.Path(name))
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to read task %s: %w", p.Path(name), err)
|
|
}
|
|
byName[name] = Task{Name: name, Path: p.Path(name), Content: string(content)}
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
used := map[string]bool{}
|
|
for _, n := range order {
|
|
t, ok := byName[n]
|
|
if !ok {
|
|
missing = append(missing, n)
|
|
continue
|
|
}
|
|
if !used[n] {
|
|
tasks = append(tasks, t)
|
|
used[n] = true
|
|
}
|
|
}
|
|
for _, n := range names {
|
|
if !used[n] {
|
|
tasks = append(tasks, byName[n])
|
|
}
|
|
}
|
|
return tasks, skipped, missing, nil
|
|
}
|
|
|
|
// EnsureMemoryFile crea memoria.md con permisos 0600 si no existe.
|
|
// Si ya existe, no modifica su contenido ni sus permisos, pero informa si
|
|
// es legible por grupo u otros para que se registre una advertencia.
|
|
func (p *Project) EnsureMemoryFile() (created, tooOpen bool, err error) {
|
|
path := p.Path(MemoryFile)
|
|
st, err := os.Stat(path)
|
|
if err == nil {
|
|
return false, st.Mode().Perm()&0o077 != 0, nil
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return false, false, fmt.Errorf("failed to stat %s: %w", path, err)
|
|
}
|
|
header := "# memoria.md\n\nConocimiento persistente descubierto por sysagent.\n"
|
|
// O_EXCL evita sobrescribir un archivo creado en paralelo.
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return false, false, fmt.Errorf("failed to create %s: %w", path, err)
|
|
}
|
|
if _, err := f.WriteString(header); err != nil {
|
|
f.Close()
|
|
return false, false, fmt.Errorf("failed to write %s: %w", path, err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return false, false, fmt.Errorf("failed to close %s: %w", path, err)
|
|
}
|
|
return true, false, nil
|
|
}
|
|
|
|
// ReadOptional lee un archivo del proyecto; devuelve "" si no existe.
|
|
func (p *Project) ReadOptional(name string) (string, error) {
|
|
data, err := os.ReadFile(p.Path(name))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read %s: %w", p.Path(name), err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// EnsureWorkDirs crea .agent/ y sus subdirectorios con permisos 0700.
|
|
func (p *Project) EnsureWorkDirs() error {
|
|
for _, sub := range []string{"", "tmp", "downloads", "patches", "logs"} {
|
|
dir := filepath.Join(p.Root, WorkDir, sub)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("failed to create %s: %w", dir, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isInstructionFile comprueba que name sea un archivo regular cuyo destino
|
|
// real (tras resolver symlinks) esté directamente dentro de PROJECT_ROOT.
|
|
// Devuelve (false, nil) si el archivo no existe.
|
|
func (p *Project) isInstructionFile(name string) (bool, error) {
|
|
if name != filepath.Base(name) {
|
|
return false, nil
|
|
}
|
|
path := p.Path(name)
|
|
real, err := filepath.EvalSymlinks(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to resolve %s: %w", path, err)
|
|
}
|
|
if filepath.Dir(real) != p.realRoot {
|
|
return false, nil
|
|
}
|
|
st, err := os.Stat(real)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to stat %s: %w", real, err)
|
|
}
|
|
return st.Mode().IsRegular(), nil
|
|
}
|