Files
2026-09-27 16:04:02 -06:00

105 lines
4.0 KiB
Go

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
}