Files
iasysadmin/internal/project/project.go
T
2026-09-27 16:04:02 -06:00

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
}