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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user