Files
iasysadmin/internal/policy/guard.go
T
2026-09-27 16:04:02 -06:00

512 lines
16 KiB
Go

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
}