512 lines
16 KiB
Go
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
|
|
}
|