post inicial
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user