post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+269
View File
@@ -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
}
+233
View File
@@ -0,0 +1,233 @@
package project
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func names(tasks []Task) string {
var s []string
for _, t := range tasks {
s = append(s, t.Name)
}
return strings.Join(s, ",")
}
func TestFindAgentFile(t *testing.T) {
dir := t.TempDir()
p, _ := Open(dir)
if _, _, err := p.FindAgentFile(); !errors.Is(err, ErrAgentNotFound) {
t.Fatalf("expected ErrAgentNotFound, got %v", err)
}
write(t, filepath.Join(dir, "Agent.md"), "x")
name, variant, err := p.FindAgentFile()
if err != nil || name != "Agent.md" || !variant {
t.Fatalf("variant: name=%q variant=%v err=%v", name, variant, err)
}
write(t, filepath.Join(dir, "agent.md"), "x")
name, variant, err = p.FindAgentFile()
if err != nil || name != "agent.md" || variant {
t.Fatalf("exact: name=%q variant=%v err=%v", name, variant, err)
}
}
func TestAgentSymlinkOutsideRejected(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
write(t, filepath.Join(outside, "agent.md"), "EVIL")
if err := os.Symlink(filepath.Join(outside, "agent.md"), filepath.Join(dir, "agent.md")); err != nil {
t.Skip("symlinks not supported:", err)
}
p, _ := Open(dir)
if _, _, err := p.FindAgentFile(); !errors.Is(err, ErrAgentNotFound) {
t.Fatalf("symlink to external agent.md must be rejected, got %v", err)
}
}
func TestDiscoverTasks(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
for _, n := range []string{"agent.md", "memoria.md", "README.md", "02-b.md", "01-a.md", "03-c.MD", "notes.txt"} {
write(t, filepath.Join(dir, n), "content "+n)
}
// Las tareas en subdirectorios nunca son instrucciones.
write(t, filepath.Join(dir, "subdir", "00-hidden.md"), "no")
// Un symlink a un .md externo tampoco.
write(t, filepath.Join(outside, "evil.md"), "rm -rf /")
symlinkOK := os.Symlink(filepath.Join(outside, "evil.md"), filepath.Join(dir, "04-evil.md")) == nil
p, _ := Open(dir)
tasks, skipped, missing, err := p.DiscoverTasks("agent.md", nil)
if err != nil {
t.Fatal(err)
}
if got := names(tasks); got != "01-a.md,02-b.md,03-c.MD" {
t.Errorf("tasks = %s", got)
}
if symlinkOK && (len(skipped) != 1 || skipped[0] != "04-evil.md") {
t.Errorf("skipped = %v", skipped)
}
if len(missing) != 0 {
t.Errorf("missing = %v", missing)
}
if tasks[0].Content != "content 01-a.md" {
t.Errorf("content = %q", tasks[0].Content)
}
}
func TestDiscoverTasksExplicitOrder(t *testing.T) {
dir := t.TempDir()
for _, n := range []string{"Agent.md", "a.md", "b.md", "c.md"} {
write(t, filepath.Join(dir, n), n)
}
p, _ := Open(dir)
tasks, _, missing, err := p.DiscoverTasks("Agent.md", []string{"c.md", "zz.md", "a.md"})
if err != nil {
t.Fatal(err)
}
if got := names(tasks); got != "c.md,a.md,b.md" {
t.Errorf("tasks = %s", got)
}
if len(missing) != 1 || missing[0] != "zz.md" {
t.Errorf("missing = %v", missing)
}
}
func TestEnsureMemoryFile(t *testing.T) {
dir := t.TempDir()
p, _ := Open(dir)
created, _, err := p.EnsureMemoryFile()
if err != nil || !created {
t.Fatalf("created=%v err=%v", created, err)
}
st, err := os.Stat(p.Path(MemoryFile))
if err != nil {
t.Fatal(err)
}
if st.Mode().Perm() != 0o600 {
t.Errorf("perm = %o, want 600", st.Mode().Perm())
}
// Segunda llamada: no recrea ni reescribe.
if err := os.WriteFile(p.Path(MemoryFile), []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(p.Path(MemoryFile), 0o644); err != nil {
t.Fatal(err)
}
created, tooOpen, err := p.EnsureMemoryFile()
if err != nil || created || !tooOpen {
t.Fatalf("created=%v tooOpen=%v err=%v", created, tooOpen, err)
}
if data, _ := os.ReadFile(p.Path(MemoryFile)); string(data) != "keep" {
t.Error("existing memoria.md must not be modified")
}
}
func TestEnsureWorkDirs(t *testing.T) {
dir := t.TempDir()
p, _ := Open(dir)
if err := p.EnsureWorkDirs(); err != nil {
t.Fatal(err)
}
st, err := os.Stat(filepath.Join(dir, WorkDir, "downloads"))
if err != nil || !st.IsDir() || st.Mode().Perm() != 0o700 {
t.Fatalf("workdir: %v %v", st, err)
}
}
func TestCheckSecurity(t *testing.T) {
dir := t.TempDir()
os.Chmod(dir, 0o700)
write(t, filepath.Join(dir, "agent.md"), "x")
write(t, filepath.Join(dir, "init.conf"), "API_KEY=x")
os.Chmod(filepath.Join(dir, "init.conf"), 0o600)
os.Chmod(filepath.Join(dir, "agent.md"), 0o644)
p, _ := Open(dir)
for _, f := range p.CheckSecurity("agent.md", nil, true) {
if f.Fatal {
t.Errorf("unexpected fatal finding: %s", f.Message)
}
}
os.Chmod(filepath.Join(dir, "agent.md"), 0o666)
fatal := false
for _, f := range p.CheckSecurity("agent.md", nil, true) {
fatal = fatal || f.Fatal && strings.Contains(f.Message, "writable by any user")
}
if !fatal {
t.Error("world-writable agent.md must be fatal")
}
os.Chmod(filepath.Join(dir, "agent.md"), 0o644)
os.Chmod(filepath.Join(dir, "init.conf"), 0o644)
warned := false
for _, f := range p.CheckSecurity("agent.md", nil, true) {
warned = warned || strings.Contains(f.Message, "chmod 600 init.conf")
}
if !warned {
t.Error("readable init.conf with API key must warn")
}
shared := t.TempDir()
os.Chmod(shared, 0o1777)
ps, _ := Open(shared)
fatal = false
for _, f := range ps.CheckSecurity("agent.md", nil, false) {
fatal = fatal || f.Fatal
}
if !fatal {
t.Error("PROJECT_ROOT in a shared /tmp-like directory must be fatal")
}
}
func TestLock(t *testing.T) {
dir := t.TempDir()
p, _ := Open(dir)
p.EnsureWorkDirs()
unlock, err := p.Lock()
if err != nil {
t.Fatal(err)
}
// flock es por descriptor abierto: un segundo Open en el mismo proceso
// simula otra instancia.
if _, err := p.Lock(); !errors.Is(err, ErrLocked) || !strings.Contains(err.Error(), "pid") {
t.Errorf("second lock: %v", err)
}
unlock()
unlock2, err := p.Lock()
if err != nil {
t.Fatalf("lock after unlock: %v", err)
}
unlock2()
}
func TestInstructionSizeLimit(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "agent.md"), strings.Repeat("x", MaxInstructionSize+1))
write(t, filepath.Join(dir, "01-big.md"), strings.Repeat("x", MaxInstructionSize+1))
write(t, filepath.Join(dir, "02-ok.md"), "ok")
p, _ := Open(dir)
if _, err := p.ReadInstructionFile("agent.md"); err == nil {
t.Error("oversized agent.md must be rejected")
}
tasks, skipped, _, _ := p.DiscoverTasks("agent.md", nil)
if len(tasks) != 1 || len(skipped) != 1 {
t.Errorf("tasks=%d skipped=%v", len(tasks), skipped)
}
}
+104
View File
@@ -0,0 +1,104 @@
package project
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
// MaxInstructionSize limita agent.md y cada archivo de tarea. Un archivo
// de instrucciones de varios MiB es casi seguro un error (o un intento de
// agotar el contexto) y no debe cargarse en el prompt.
const MaxInstructionSize = 1 << 20
// Finding es un resultado de la revisión de seguridad del proyecto.
type Finding struct {
Fatal bool // impide arrancar
Message string
}
// CheckSecurity revisa permisos y propietarios de lo que da instrucciones o
// credenciales al agente.
//
// Motivo: si otro usuario puede escribir agent.md, init.conf, un archivo de
// tarea o el propio PROJECT_ROOT (para crear tareas nuevas), puede decidir
// qué hace el agente. Si el agente corre como root, eso es una escalada de
// privilegios. Por eso:
//
// - escribible por "otros" (o+w): error fatal;
// - escribible por el grupo (g+w): advertencia;
// - con el agente como root, propietario distinto de root: advertencia;
// - init.conf legible por grupo/otros con API_KEY definida: advertencia.
func (p *Project) CheckSecurity(agentFile string, tasks []Task, hasAPIKey bool) []Finding {
var out []Finding
euid := os.Geteuid()
check := func(path, what string) {
st, err := os.Stat(path)
if err != nil {
return
}
mode := st.Mode()
sticky := mode&os.ModeSticky != 0
switch {
case mode.Perm()&0o002 != 0 && !(mode.IsDir() && sticky):
out = append(out, Finding{Fatal: true, Message: fmt.Sprintf("%s %s is writable by any user (mode %04o): anyone could control the agent; fix with chmod o-w", what, path, mode.Perm())})
case mode.IsDir() && sticky && mode.Perm()&0o002 != 0:
out = append(out, Finding{Fatal: true, Message: fmt.Sprintf("%s %s is a shared world-writable directory (like /tmp): any user could add task files; use a private directory", what, path)})
case mode.Perm()&0o020 != 0:
out = append(out, Finding{Message: fmt.Sprintf("%s %s is group-writable (mode %04o); members of its group can change the agent's instructions", what, path, mode.Perm())})
}
if sys, ok := st.Sys().(*syscall.Stat_t); ok && euid == 0 && sys.Uid != 0 {
out = append(out, Finding{Message: fmt.Sprintf("%s %s is owned by uid %d but the agent runs as root: that user controls a root process", what, path, sys.Uid)})
}
}
check(p.Root, "PROJECT_ROOT")
check(p.Path(agentFile), "agent file")
check(p.Path("init.conf"), "init.conf")
for _, t := range tasks {
check(t.Path, "task file")
}
if hasAPIKey {
if st, err := os.Stat(p.Path("init.conf")); err == nil && st.Mode().Perm()&0o044 != 0 {
out = append(out, Finding{Message: fmt.Sprintf("init.conf contains API_KEY and is readable by other users (mode %04o); fix with chmod 600 init.conf", st.Mode().Perm())})
}
}
return out
}
// ErrLocked indica que otra instancia usa el mismo PROJECT_ROOT.
var ErrLocked = errors.New("another sysagent instance is running in this PROJECT_ROOT")
// Lock impide que dos instancias trabajen a la vez sobre el mismo proyecto,
// lo que corrompería contexto.mem y podría duplicar acciones sobre el
// sistema. Usa flock(2): el bloqueo desaparece solo si el proceso muere.
// Devuelve la función que lo libera.
func (p *Project) Lock() (unlock func(), err error) {
path := filepath.Join(p.Root, WorkDir, "lock")
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("failed to open lock file %s: %w", path, err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
data := make([]byte, 32)
n, _ := f.ReadAt(data, 0)
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
if pid := strings.TrimSpace(string(data[:n])); pid != "" {
return nil, fmt.Errorf("%w (pid %s)", ErrLocked, pid)
}
return nil, ErrLocked
}
return nil, fmt.Errorf("failed to lock %s: %w", path, err)
}
f.Truncate(0)
f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0)
return func() {
f.Truncate(0)
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}