// 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 }