package policy import ( "errors" "fmt" "os" "path/filepath" "strings" ) // Op es el tipo de acceso a una ruta. type Op int const ( OpRead Op = iota OpWrite OpDelete ) func (o Op) String() string { switch o { case OpRead: return "read" case OpWrite: return "write" default: return "delete" } } // DeniedError indica que la política impide una operación. El mensaje se // devuelve al modelo para que pueda buscar una alternativa permitida. type DeniedError struct{ Reason string } func (e *DeniedError) Error() string { return "denied by policy: " + e.Reason } // IsDenied informa si err es una denegación de política. func IsDenied(err error) bool { var d *DeniedError return errors.As(err, &d) } func deny(format string, args ...any) error { return &DeniedError{Reason: fmt.Sprintf(format, args...)} } // criticalPaths nunca pueden borrarse, en ningún modo (regla inmutable). var criticalPaths = set( "/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/libx32", "/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/var", "/usr/bin", "/usr/lib", "/usr/lib64", "/usr/sbin", "/usr/local", "/usr/share", "/usr/include", "/var/lib", "/var/log", "/var/cache", "/var/spool", "/etc/ssh", "/etc/systemd", "/etc/apt", "/efi", "/boot/efi", ) // Guard aplica la política a comandos y rutas concretos. // // Es la única puerta de entrada de las herramientas: ninguna herramienta // modifica el sistema sin pasar antes por un método Check*. type Guard struct { Policy *Policy Root string // PROJECT_ROOT realRoot string agentFile string } // NewGuard crea un Guard para el proyecto. agentFile es el nombre real del // archivo agent.md (puede diferir en mayúsculas). func NewGuard(p *Policy, root, agentFile string) (*Guard, error) { real, err := filepath.EvalSymlinks(root) if err != nil { return nil, fmt.Errorf("failed to resolve project root %s: %w", root, err) } return &Guard{Policy: p, Root: root, realRoot: real, agentFile: agentFile}, nil } // Resolve convierte una ruta en absoluta y limpia. Las relativas se // interpretan respecto a base (PROJECT_ROOT si base está vacío) y "~" se // expande al HOME del proceso. func (g *Guard) Resolve(p, base string) string { if p == "~" || strings.HasPrefix(p, "~/") { if home, err := os.UserHomeDir(); err == nil { p = filepath.Join(home, strings.TrimPrefix(p, "~")) } } if !filepath.IsAbs(p) { if base == "" { base = g.Root } p = filepath.Join(base, p) } return filepath.Clean(p) } // RealPath resuelve symlinks del ancestro existente más profundo y añade el // resto de la ruta. Así, "proyecto/link/../x" o un symlink del proyecto hacia // /etc se evalúan por su destino real. // // Con followLast=false no se sigue el último componente (para borrar o // renombrar un symlink se actúa sobre el enlace, no sobre su destino). func RealPath(abs string, followLast bool) string { dir, last := abs, "" if !followLast { dir, last = filepath.Dir(abs), filepath.Base(abs) if abs == "/" { dir, last = "/", "" } } rest := []string{} cur := dir for { if r, err := filepath.EvalSymlinks(cur); err == nil { parts := append([]string{r}, rest...) if last != "" { parts = append(parts, last) } return filepath.Join(parts...) } parent := filepath.Dir(cur) if parent == cur { return abs } rest = append([]string{filepath.Base(cur)}, rest...) cur = parent } } // Inside informa si una ruta real está dentro de PROJECT_ROOT. func (g *Guard) Inside(real string) bool { return real == g.realRoot || strings.HasPrefix(real, g.realRoot+string(filepath.Separator)) } // protected clasifica archivos del proyecto que el agente no puede tocar: // sus propias instrucciones, su configuración y su auditoría. // // Motivo: si el agente pudiera editar agent.md, init.conf o crear nuevos // *.md de tarea en la raíz, podría ampliar sus propios permisos o // inyectarse instrucciones para la siguiente ejecución. func (g *Guard) protected(real string) (writeProtected, readProtected bool, why string) { if filepath.Dir(real) != g.realRoot { return false, false, "" } base := filepath.Base(real) lower := strings.ToLower(base) switch { case lower == "init.conf": return true, true, "init.conf contains the API credentials and is managed by the operator" case strings.EqualFold(base, g.agentFile) || lower == "agent.md": return true, false, "agent.md is the agent's authority and cannot be modified by the agent" case lower == "agent.log": return true, false, "agent.log is the audit log and cannot be modified by the agent" case lower == "contexto.mem": return true, false, "contexto.mem is managed by the program" case lower == ".context-history": return true, true, ".context-history holds the operator's input and is managed by the program" case lower == "memoria.md": return false, false, "" case strings.HasSuffix(lower, ".md"): return true, false, "*.md files in PROJECT_ROOT are task instructions; the agent cannot create or modify them" } return false, false, "" } // CheckPath decide si una operación sobre una ruta está permitida. // p puede ser relativa (respecto a base) o absoluta. func (g *Guard) CheckPath(op Op, p, base string) error { abs := g.Resolve(p, base) real := RealPath(abs, op != OpDelete) if op == OpDelete { if criticalPaths[real] || criticalPaths[abs] { return deny("%s is a critical system path and can never be deleted", abs) } // "rm -rf /etc/*": el comodín lo expande el shell a todo el contenido. if strings.ContainsAny(filepath.Base(abs), "*?[") && (criticalPaths[filepath.Dir(abs)] || criticalPaths[filepath.Dir(real)] || filepath.Dir(real) == g.realRoot) { return deny("wildcard deletion inside %s is not allowed", filepath.Dir(abs)) } if real == g.realRoot { return deny("PROJECT_ROOT itself cannot be deleted") } if !g.Policy.Allows(CapDelete) { return deny("deleting %s requires ALLOW_DELETE=true", abs) } } wp, rp, why := g.protected(real) if op == OpRead && rp || op != OpRead && wp { return deny("%s: %s", abs, why) } if op == OpDelete && real == g.realRoot+string(filepath.Separator)+".agent" { return deny("the .agent work directory cannot be deleted") } if g.Inside(real) { if op != OpRead && strings.HasPrefix(real, filepath.Join(g.realRoot, "tools")+string(filepath.Separator)) && !g.Policy.Allows(CapSelfToolCreation) { return deny("writing in %s/tools requires ALLOW_SELF_TOOL_CREATION=true", g.Root) } return nil } switch op { case OpRead: if !g.Policy.Allows(CapOutsideRead) { return deny("reading %s (outside PROJECT_ROOT) requires ALLOW_OUTSIDE_PROJECT_READ=true", abs) } default: if !g.Policy.Allows(CapOutsideWrite) { return deny("modifying %s (outside PROJECT_ROOT) requires ALLOW_OUTSIDE_PROJECT_WRITE=true", abs) } } return nil } // CheckCapability exige que todas las capacidades estén concedidas. // Lo usan las herramientas de red (búsqueda web, descargas). func (g *Guard) CheckCapability(what string, caps ...Capability) error { for _, c := range caps { if !g.Policy.Allows(c) { return deny("%s requires %s=true", what, c) } } return nil } // CheckArgv decide si un comando (argv) puede ejecutarse en cwd. // Devuelve también su clasificación, útil para --dry-run y auditoría. func (g *Guard) CheckArgv(argv []string, cwd string) (Classification, error) { if len(argv) == 0 || argv[0] == "" { return Classification{}, fmt.Errorf("empty command") } if err := g.CheckPath(OpRead, cwd, ""); err != nil { return Classification{}, err } c := Classify(argv) return c, g.checkClassification(c, cwd) } // CheckScript decide si una expresión de shell puede ejecutarse. func (g *Guard) CheckScript(script, cwd string) (Classification, error) { if err := g.CheckPath(OpRead, cwd, ""); err != nil { return Classification{}, err } s := ParseShell(script) c := Classification{Program: "shell", Script: &s, Risks: []Risk{RiskReadOnly}} if len(s.Opaque) > 0 { c.Risks = []Risk{RiskUnknown} } for _, sc := range s.Commands { c.Nested = append(c.Nested, Classify(sc.Argv)) } if len(s.Opaque) > 0 && g.Policy.Mode == ModeGuarded { return c, deny("guarded mode: shell construct not analyzable (%s); use the exec tool or a simpler expression", strings.Join(s.Opaque, ", ")) } return c, g.checkScript(&s, cwd) } // checkScript evalúa cada comando del script en orden, siguiendo los "cd" // para que las rutas relativas se resuelvan en el directorio correcto. // Si un cd tiene destino dinámico, el directorio pasa a ser "/" (lo más // conservador: toda ruta relativa se considera fuera del proyecto). func (g *Guard) checkScript(s *ShellScript, cwd string) error { cur := g.Resolve(cwd, "") for _, sc := range s.Commands { if err := g.checkClassification(Classify(sc.Argv), cur); err != nil { return err } if err := g.checkRedirects(sc.Redirects, cur); err != nil { return err } if len(sc.Argv) > 0 && (sc.Argv[0] == "cd" || sc.Argv[0] == "pushd") { target := firstNonFlag(sc.Argv[1:]) switch { case target == "": if home, err := os.UserHomeDir(); err == nil { cur = home } else { cur = "/" } case strings.ContainsAny(target, "$`*?") || target == "-": cur = "/" default: cur = g.Resolve(target, cur) } } } return nil } // checkClassification aplica la política a un comando y sus anidados. func (g *Guard) checkClassification(c Classification, cwd string) error { for _, r := range c.Risks { if err := g.checkRisk(r, c, cwd); err != nil { return err } } if err := g.checkArgPaths(c, cwd); err != nil { return err } if c.Script != nil { // bash -c '...': el script anidado se evalúa completo (con sus cd y // redirecciones); sus comandos ya no se revisan de nuevo abajo. if len(c.Script.Opaque) > 0 && g.Policy.Mode == ModeGuarded { return deny("guarded mode: nested shell script not analyzable (%s)", strings.Join(c.Script.Opaque, ", ")) } return g.checkScript(c.Script, cwd) } for _, n := range c.Nested { if err := g.checkClassification(n, cwd); err != nil { return err } } return nil } // checkRisk traduce una categoría de riesgo en una decisión. // // Tabla de decisión: // // read-only siempre // network ALLOW_NETWORK // download ALLOW_DOWNLOADS // write privileged+, o guarded si todo ocurre dentro de PROJECT_ROOT // delete ALLOW_DELETE // package ALLOW_PACKAGE_INSTALL // service ALLOW_SERVICE_RESTART // admin privileged+ // destructive unrestricted // reboot ALLOW_REBOOT (en todos los modos) // unknown privileged+ func (g *Guard) checkRisk(r Risk, c Classification, cwd string) error { p := g.Policy name := c.Program switch r { case RiskReadOnly: return nil case RiskNetwork: if !p.Allows(CapNetwork) { return deny("%s uses the network and requires ALLOW_NETWORK=true", name) } case RiskDownload: if !p.Allows(CapDownloads) { return deny("%s downloads files and requires ALLOW_DOWNLOADS=true", name) } case RiskWrite: if p.Mode == ModeGuarded && !g.allInsideProject(c, cwd) { return deny("guarded mode: %s modifies files outside PROJECT_ROOT (or its targets are unknown); use the file tools or privileged mode", name) } case RiskDelete: if !p.Allows(CapDelete) { return deny("%s deletes data and requires ALLOW_DELETE=true", name) } case RiskPackage: if !p.Allows(CapPackageInstall) { return deny("%s manages packages and requires ALLOW_PACKAGE_INSTALL=true", name) } case RiskService: if !p.Allows(CapServiceRestart) { return deny("%s controls services and requires ALLOW_SERVICE_RESTART=true", name) } case RiskAdmin: if p.Mode == ModeGuarded { return deny("guarded mode: %s changes system configuration and requires EXECUTION_MODE=privileged", name) } case RiskDestructive: if p.Mode != ModeUnrestricted { return deny("%s is potentially destructive and requires EXECUTION_MODE=unrestricted", name) } case RiskReboot: if !p.Allows(CapReboot) { return deny("%s reboots or powers off the system and requires ALLOW_REBOOT=true", name) } case RiskUnknown: if p.Mode == ModeGuarded { return deny("guarded mode: %s is not a known read-only command", name) } } return nil } // checkArgPaths revisa los argumentos que parecen rutas: lectura para // comandos de solo lectura, escritura para los que modifican. Es una // aproximación: argumentos que no son rutas pueden parecerlo, lo que // produce falsos positivos conservadores, nunca falsos negativos // para rutas absolutas. func (g *Guard) checkArgPaths(c Classification, cwd string) error { if len(c.Argv) < 2 { return nil } op := OpRead for _, r := range c.Risks { switch r { case RiskDelete, RiskDestructive: op = OpDelete case RiskWrite, RiskAdmin, RiskUnknown: if op != OpDelete { op = OpWrite } } } for _, a := range pathArgs(c.Argv[1:], c.AllArgsArePaths) { if err := g.CheckPath(op, a, cwd); err != nil { return err } } // Los archivos protegidos del proyecto se revisan en todos los // argumentos, aunque no tengan forma de ruta ("cat init.conf"). for _, a := range pathArgs(c.Argv[1:], true) { wp, rp, why := g.protected(RealPath(g.Resolve(a, cwd), true)) if op == OpRead && rp || op != OpRead && wp { return deny("%s: %s", a, why) } } return nil } func (g *Guard) checkRedirects(rs []Redirect, cwd string) error { for _, r := range rs { t := r.Target if r.Op == "<<" || r.Op == "<<-" || r.Op == "<<<" { continue // heredoc / herestring: no es un archivo } if strings.HasSuffix(r.Op, "&") && (t == "-" || isNumber(t)) { continue // duplicación de descriptor: 2>&1 } if t == "/dev/null" || t == "/dev/stdout" || t == "/dev/stderr" || t == "/dev/tty" || strings.HasPrefix(t, "/dev/fd/") { continue } if strings.ContainsAny(t, "$`") { if g.Policy.Mode == ModeGuarded { return deny("guarded mode: redirection target %q is dynamic", t) } continue } op := OpRead if r.Writes() { op = OpWrite } if err := g.CheckPath(op, t, cwd); err != nil { return err } } return nil } // allInsideProject informa si cwd y todas las rutas afectadas por un comando // de escritura están dentro de PROJECT_ROOT. func (g *Guard) allInsideProject(c Classification, cwd string) bool { if !g.Inside(RealPath(g.Resolve(cwd, ""), true)) { return false } if len(c.Argv) < 2 { return true } for _, a := range pathArgs(c.Argv[1:], true) { if strings.ContainsAny(a, "$`*?") { return false } if !g.Inside(RealPath(g.Resolve(a, cwd), true)) { return false } } return true } // pathArgs extrae los argumentos que deben tratarse como rutas. // Con all=true, todo argumento no-opción es una ruta (rm, cp, chmod...). // Si no, solo los que tienen forma de ruta (/x, ./x, ../x, ~/x, ., ..). // También se consideran valores de opciones largas "--opt=/ruta". func pathArgs(args []string, all bool) []string { var out []string endOfFlags := false for _, a := range args { if a == "--" { endOfFlags = true continue } if !endOfFlags && strings.HasPrefix(a, "-") { if i := strings.Index(a, "="); i > 0 && looksLikePath(a[i+1:]) { out = append(out, a[i+1:]) } continue } if a == "" || strings.Contains(a, "://") || a == "{}" || a == ";" || a == "+" { continue } if all || looksLikePath(a) { out = append(out, a) } } return out } func looksLikePath(a string) bool { if strings.Contains(a, "://") { return false } return strings.HasPrefix(a, "/") || strings.HasPrefix(a, "./") || strings.HasPrefix(a, "../") || strings.HasPrefix(a, "~/") || a == "." || a == ".." || a == "~" } func isNumber(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true }