post inicial
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user