post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+783
View File
@@ -0,0 +1,783 @@
package policy
import (
"path/filepath"
"strings"
)
// Risk es la categoría de riesgo de un comando.
type Risk string
const (
RiskReadOnly Risk = "read-only"
RiskNetwork Risk = "network"
RiskDownload Risk = "download"
RiskWrite Risk = "write"
RiskDelete Risk = "delete"
RiskPackage Risk = "package"
RiskService Risk = "service"
RiskAdmin Risk = "admin"
RiskDestructive Risk = "destructive"
RiskReboot Risk = "reboot"
RiskUnknown Risk = "unknown"
)
// Classification describe un comando concreto (argv) tras desenvolver
// envoltorios como sudo, env o timeout.
type Classification struct {
Program string // nombre base del programa efectivo
Argv []string
Risks []Risk
// AllArgsArePaths indica que todos los argumentos no-opción deben
// tratarse como rutas afectadas (rm, cp, chmod...).
AllArgsArePaths bool
// Nested contiene comandos anidados (bash -c, find -exec, xargs...).
Nested []Classification
// Script es el script de un "bash -c" anidado, si lo hay.
Script *ShellScript
}
// AllRisks devuelve los riesgos propios y de todos los comandos anidados.
func (c Classification) AllRisks() []Risk {
out := append([]Risk(nil), c.Risks...)
for _, n := range c.Nested {
out = append(out, n.AllRisks()...)
}
return out
}
// ReadOnly indica si el comando y todos sus anidados son de solo lectura
// (o solo red), es decir, seguros de ejecutar en --dry-run.
func (c Classification) ReadOnly() bool {
for _, r := range c.AllRisks() {
if r != RiskReadOnly && r != RiskNetwork {
return false
}
}
return true
}
// readOnlyPrograms nunca modifican el sistema, sean cuales sean sus argumentos.
var readOnlyPrograms = set(
"ls", "dir", "cat", "tac", "head", "tail", "grep", "egrep", "fgrep", "rg", "zgrep", "zcat", "bzcat", "xzcat", "zstdcat",
"stat", "file", "wc", "du", "df", "free", "uptime", "uname", "id", "whoami", "groups", "who", "w", "last", "lastlog",
"ps", "pgrep", "pidof", "ss", "netstat", "lsof", "lsblk", "blkid", "lscpu", "lsmem", "lspci", "lsusb", "lsmod",
"lsattr", "lsns", "lsipc", "lslocks", "lslogins", "findmnt", "cal", "printenv", "echo", "printf", "true", "false",
"test", "[", "[[", "basename", "dirname", "realpath", "readlink", "pwd", "which", "whereis", "type", "sort", "uniq",
"cut", "tr", "column", "nl", "fold", "fmt", "paste", "join", "comm", "diff", "cmp", "md5sum", "sha1sum", "sha224sum",
"sha256sum", "sha384sum", "sha512sum", "b2sum", "cksum", "base64", "base32", "xxd", "hexdump", "od", "strings", "jq",
"getent", "getconf", "nproc", "arch", "locale", "vmstat", "iostat", "mpstat", "pidstat", "sar", "systemd-analyze",
"modinfo", "tree", "locate", "dmidecode", "sensors", "aa-status", "getenforce", "sestatus", "apt-cache", "dpkg-query",
"named-checkconf", "named-checkzone", "cd", "export", "unset", "set", "read", "sleep", "wait", "exit", "return",
"local", "shift", "declare", "typeset", "readonly", "let", "seq", "yes", "expr", "tput", "ldd", "nm",
"objdump", "readelf", "lsinitramfs", "systemd-detect-virt", "virt-what", "numfmt", "rev", "shuf", "iconv", "less",
"more", "man", "whatis", "apropos", "hash", "ulimit", "logname", "tty", "users",
"free", "systemd-cgls", "systemd-cgtop", "diff3", "sdiff", "look", "zdiff", "zless", "zmore", "cmp")
// networkPrograms solo consultan la red.
var networkPrograms = set(
"ping", "ping6", "traceroute", "traceroute6", "tracepath", "mtr", "dig", "host", "nslookup", "whois", "nc", "ncat",
"netcat", "telnet", "nmap", "arping", "ssh", "sftp", "ftp", "drill", "delv", "kdig", "socat",
)
// destructivePrograms pueden destruir datos de forma irreversible.
var destructivePrograms = set(
"dd", "mkfs", "mke2fs", "mkswap", "wipefs", "fdisk", "sfdisk", "cfdisk", "gdisk", "sgdisk", "parted", "shred",
"blkdiscard", "pvremove", "vgremove", "lvremove", "userdel", "deluser", "groupdel", "delgroup", "killall5",
"mdadm", "zpool", "badblocks",
)
// adminPrograms modifican la configuración del sistema.
var adminPrograms = set(
"chmod", "chown", "chgrp", "setfacl", "chattr", "useradd", "usermod", "groupadd", "groupmod", "gpasswd", "passwd",
"chpasswd", "adduser", "addgroup", "chsh", "chfn", "kill", "pkill", "killall", "umount", "mount", "swapoff",
"modprobe", "insmod", "rmmod", "depmod", "chroot", "pvcreate", "vgcreate", "lvcreate", "lvextend", "lvresize",
"resize2fs", "xfs_growfs", "tune2fs", "e2fsck", "fsck", "update-grub", "grub-install", "update-initramfs",
"update-alternatives", "ldconfig", "setcap", "newaliases", "postmap", "a2enmod", "a2dismod", "a2ensite",
"a2dissite", "locale-gen", "update-ca-certificates", "certbot", "iptables-restore", "ip6tables-restore",
"renice", "systemd-tmpfiles", "setenforce", "exportfs", "quotaon", "quotaoff",
)
// writePrograms escriben archivos (sin llegar a administración del sistema).
var writePrograms = set(
"cp", "mv", "install", "ln", "touch", "mkdir", "tee", "truncate", "patch", "mkfifo", "mknod", "split", "csplit",
"make", "cmake", "ninja", "meson", "gcc", "cc", "g++", "c++", "clang", "clang++", "ld", "as", "rustc", "cargo", "go",
"javac", "ar", "strip", "zip", "7z", "convert", "sponge", "dos2unix", "unix2dos", "ssh-keygen",
)
// packagePrograms: gestores de paquetes (subcomandos de lectura se tratan aparte).
var packageReadVerbs = map[string]map[string]bool{
"apt": set("list", "show", "search", "policy", "depends", "rdepends", "showsrc", "changelog", "madison", "help", "moo"),
"apt-get": set("check", "changelog", "help"),
"dnf": set("list", "info", "search", "repolist", "repoquery", "provides", "whatprovides", "history", "check-update", "help", "deplist", "updateinfo", "module-list"),
"yum": set("list", "info", "search", "repolist", "provides", "whatprovides", "history", "check-update", "help", "deplist", "updateinfo"),
"zypper": set("search", "se", "info", "if", "list-updates", "lu", "repos", "lr", "packages", "pa", "patches", "lp", "help"),
"apk": set("info", "search", "list", "policy", "version", "dot", "stats", "help"),
"snap": set("list", "info", "find", "search", "version", "help", "changes", "services", "connections"),
"flatpak": set("list", "info", "search", "remote-ls", "remotes", "history", "help"),
"pip": set("list", "show", "freeze", "check", "help", "search", "inspect", "debug", "config"),
"pip3": set("list", "show", "freeze", "check", "help", "search", "inspect", "debug", "config"),
}
func set(items ...string) map[string]bool {
m := make(map[string]bool, len(items))
for _, it := range items {
m[it] = true
}
return m
}
// Classify clasifica un argv. Nunca devuelve una lista de riesgos vacía.
func Classify(argv []string) Classification {
return classify(argv, 0)
}
func classify(argv []string, depth int) Classification {
if len(argv) == 0 {
return Classification{Risks: []Risk{RiskReadOnly}}
}
if depth > maxShellDepth {
return Classification{Program: argv[0], Argv: argv, Risks: []Risk{RiskUnknown}}
}
prog := filepath.Base(argv[0])
args := argv[1:]
c := Classification{Program: prog, Argv: argv}
// Un nombre de programa dinámico ($CMD, $(...)) no se puede clasificar.
if strings.ContainsAny(argv[0], "$`") {
c.Risks = []Risk{RiskUnknown}
return c
}
// Envoltorios: el riesgo es el del comando interior.
if inner, ok := unwrap(prog, args); ok {
if inner == nil {
c.Risks = []Risk{RiskUnknown}
return c
}
n := classify(inner, depth+1)
n.Nested = append([]Classification(nil), n.Nested...)
return n
}
// Intérpretes de shell con -c: se analiza el script.
if isShell(prog) {
if script, ok := shellDashC(args); ok {
s := ParseShell(script)
c.Script = &s
c.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, depth+1))
}
return c
}
c.Risks = []Risk{RiskUnknown}
return c
}
// Consultas de versión/ayuda: seguras para cualquier programa.
if len(args) == 1 && (args[0] == "--version" || args[0] == "--help" || args[0] == "-version") {
c.Risks = []Risk{RiskReadOnly}
return c
}
c.Risks, c.AllArgsArePaths, c.Nested = programRisks(prog, args, depth)
return c
}
// programRisks contiene las reglas por programa y subcomando.
func programRisks(prog string, args []string, depth int) (risks []Risk, allPaths bool, nested []Classification) {
verb := firstNonFlag(args)
has := func(flags ...string) bool { return hasAny(args, flags...) }
one := func(r Risk) ([]Risk, bool, []Classification) { return []Risk{r}, false, nil }
switch prog {
case "rm", "rmdir", "unlink":
return []Risk{RiskDelete}, true, nil
case "cp", "mv", "install", "ln", "touch", "mkdir", "truncate", "tee", "mkfifo":
return []Risk{RiskWrite}, true, nil
case "chmod", "chown", "chgrp", "setfacl", "chattr":
return []Risk{RiskAdmin}, true, nil
case "shred":
return []Risk{RiskDestructive}, true, nil
case "find":
return classifyFind(args, depth)
case "xargs":
inner := skipFlags(args, "-n", "-I", "-P", "-d", "-L", "-s", "-E", "-a", "--max-args", "--max-procs", "--delimiter", "--arg-file")
if len(inner) == 0 {
return one(RiskReadOnly) // xargs sin comando ejecuta echo
}
n := classify(inner, depth+1)
return n.Risks, n.AllArgsArePaths, n.Nested
case "systemctl":
switch verb {
case "", "status", "is-active", "is-enabled", "is-failed", "is-system-running", "show", "cat", "list-units",
"list-unit-files", "list-sockets", "list-timers", "list-jobs", "list-dependencies", "list-machines",
"help", "get-default", "show-environment":
return one(RiskReadOnly)
case "start", "stop", "restart", "reload", "try-restart", "reload-or-restart", "try-reload-or-restart",
"enable", "disable", "reenable", "mask", "unmask", "daemon-reload", "daemon-reexec", "kill", "reset-failed",
"condrestart", "force-reload", "preset", "revert":
return one(RiskService)
case "reboot", "poweroff", "halt", "kexec", "suspend", "hibernate", "hybrid-sleep", "suspend-then-hibernate",
"rescue", "emergency", "default", "isolate", "soft-reboot", "switch-root":
return one(RiskReboot)
}
return one(RiskAdmin)
case "service":
if has("--status-all") || len(args) >= 2 && args[1] == "status" {
return one(RiskReadOnly)
}
return one(RiskService)
case "reboot", "shutdown", "poweroff", "halt":
return one(RiskReboot)
case "init", "telinit":
if verb == "0" || verb == "6" {
return one(RiskReboot)
}
return one(RiskAdmin)
case "apt", "apt-get", "aptitude", "dnf", "yum", "microdnf", "zypper", "apk", "snap", "flatpak", "pip", "pip3":
if verb == "" || packageReadVerbs[prog][verb] {
return one(RiskReadOnly)
}
return []Risk{RiskPackage, RiskNetwork}, false, nil
case "apt-mark", "dpkg-reconfigure", "debconf-set-selections", "add-apt-repository":
if prog == "apt-mark" && (verb == "showhold" || verb == "showauto" || verb == "showmanual") {
return one(RiskReadOnly)
}
return one(RiskPackage)
case "dpkg":
if has("-i", "--install", "-r", "--remove", "-P", "--purge", "--configure", "--unpack", "-a", "--set-selections",
"--clear-selections", "--add-architecture", "--remove-architecture", "-x", "--extract", "-b", "--build") {
return one(RiskPackage)
}
return one(RiskReadOnly)
case "rpm":
if has("-i", "--install", "-U", "--upgrade", "-e", "--erase", "-F", "--freshen", "--import", "--rebuilddb") {
return one(RiskPackage)
}
return one(RiskReadOnly)
case "pacman":
if verb == "" && len(args) > 0 {
f := args[0]
if strings.HasPrefix(f, "-Q") || strings.HasPrefix(f, "-F") || f == "-Ss" || f == "-Si" || f == "-Sl" || f == "-Sg" {
return one(RiskReadOnly)
}
} else if len(args) > 0 && (strings.HasPrefix(args[0], "-Q") || args[0] == "-Ss" || args[0] == "-Si") {
return one(RiskReadOnly)
}
return []Risk{RiskPackage, RiskNetwork}, false, nil
case "ip":
return classifyIP(args)
case "nft":
if verb == "list" || verb == "describe" || has("-c", "--check") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "iptables", "ip6tables", "iptables-legacy", "ip6tables-legacy", "iptables-nft", "ip6tables-nft", "ebtables", "arptables":
if onlyFlags(args, "-L", "--list", "-S", "--list-rules", "-n", "--numeric", "-v", "--verbose", "-t", "--table", "-x", "--line-numbers", "-C", "--check") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "iptables-save", "ip6tables-save":
return one(RiskReadOnly)
case "ufw":
if verb == "status" || verb == "show" || verb == "app" && len(args) > 1 && (args[1] == "list" || args[1] == "info") || verb == "version" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "firewall-cmd":
for _, a := range args {
if !(strings.HasPrefix(a, "--list") || strings.HasPrefix(a, "--get") || strings.HasPrefix(a, "--query") ||
a == "--state" || strings.HasPrefix(a, "--zone") || a == "--permanent" || strings.HasPrefix(a, "--info")) {
return one(RiskAdmin)
}
}
return one(RiskReadOnly)
case "tc", "bridge":
if verb == "" || containsWord(args, "show", "list", "ls") && !containsWord(args, "add", "del", "delete", "replace", "change", "set", "flush") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "nmcli":
if containsWord(args, "modify", "up", "down", "add", "delete", "del", "reload", "import", "connect", "disconnect", "on", "off", "set", "edit", "clone") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "networkctl", "resolvectl", "loginctl", "hostnamectl", "timedatectl", "localectl", "busctl", "coredumpctl", "machinectl":
if verb == "" || verb == "status" || verb == "show" || verb == "query" || verb == "list" || strings.HasPrefix(verb, "list") ||
verb == "statistics" || verb == "dns" || verb == "domain" || verb == "info" || verb == "cat-config" || verb == "timesync-status" ||
verb == "show-timesync" || verb == "tree" || verb == "introspect" || verb == "user-status" || verb == "session-status" || verb == "seat-status" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "ethtool":
if has("-s", "--change", "-K", "--features", "--offload", "-G", "--set-ring", "-A", "--pause", "-C", "--coalesce",
"-L", "--set-channels", "-E", "--change-eeprom", "-f", "--flash", "-r", "--negotiate", "-p", "--identify", "--reset", "-X", "-N", "-U") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "iw", "wg":
if containsWord(args, "set", "add", "del", "connect", "disconnect", "setconf", "addconf", "syncconf") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "mount":
if len(args) == 0 || onlyFlags(args, "-l", "--show-labels", "-t", "--types", "-v") && !hasNonFlag(args, "-t", "--types") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "swapon":
if len(args) == 0 || has("--show", "-s", "--summary") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "sysctl":
for _, a := range args {
if a == "-w" || a == "--write" || a == "-p" || a == "--load" || a == "--system" || strings.Contains(a, "=") && !strings.HasPrefix(a, "-") {
return one(RiskAdmin)
}
}
return one(RiskReadOnly)
case "hostname":
if verb == "" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "date":
if has("-s", "--set") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "dmesg":
if has("-c", "-C", "--clear", "--read-clear", "-D", "-E", "-n", "--console-off", "--console-on", "--console-level") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "journalctl":
for _, a := range args {
if strings.HasPrefix(a, "--vacuum") {
return []Risk{RiskDelete}, false, nil
}
if a == "--rotate" || a == "--flush" || a == "--relinquish-var" || a == "--sync" || a == "--setup-keys" {
return one(RiskAdmin)
}
}
return one(RiskReadOnly)
case "crontab":
if has("-l") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "top", "htop", "iotop", "atop":
if has("-b", "--batch") {
return one(RiskReadOnly)
}
return one(RiskUnknown) // interactivo: se colgaría hasta el timeout
case "kill":
if has("-l", "-L", "--list", "--table") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "smartctl":
if has("-t", "--test", "-s", "--smart", "-o", "--offlineauto", "-S", "--saveauto", "-X", "--abort") {
return one(RiskAdmin)
}
return one(RiskReadOnly)
case "cryptsetup":
switch verb {
case "status", "luksDump", "isLuks", "luksUUID", "tcryptDump", "bitlkDump":
return one(RiskReadOnly)
case "luksFormat", "erase", "luksErase", "reencrypt", "luksKillSlot", "luksRemoveKey":
return one(RiskDestructive)
}
return one(RiskAdmin)
case "zfs":
if verb == "destroy" {
return one(RiskDestructive)
}
if verb == "list" || verb == "get" || verb == "status" {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "lvm", "lvs", "vgs", "pvs", "lvdisplay", "vgdisplay", "pvdisplay", "pvscan", "vgscan", "lvscan":
return one(RiskReadOnly)
case "sed":
for _, a := range args {
if a == "-i" || strings.HasPrefix(a, "-i") && !strings.HasPrefix(a, "--") || strings.HasPrefix(a, "--in-place") ||
len(a) > 1 && a[0] == '-' && a[1] != '-' && strings.Contains(a, "i") {
return []Risk{RiskWrite}, true, nil
}
}
return one(RiskReadOnly)
case "awk", "gawk", "mawk", "nawk", "perl", "python", "python3", "ruby", "node", "php", "lua", "tclsh", "Rscript":
// Lenguajes con E/S y system(): no se pueden evaluar.
return one(RiskUnknown)
case "tar", "bsdtar":
// Modo de listado: "tf", "-tzf", "--list" (sin crear ni extraer).
mode := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
mode = strings.TrimPrefix(args[0], "-")
}
if has("--list") || strings.Contains(mode, "t") && !strings.ContainsAny(mode, "cxruA") {
return one(RiskReadOnly)
}
return []Risk{RiskWrite}, false, nil
case "unzip":
if has("-l", "-v", "-t", "-Z", "-p") {
return one(RiskReadOnly)
}
return []Risk{RiskWrite}, false, nil
case "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd", "unzstd", "lz4", "lzma":
if has("-l", "--list", "-t", "--test", "-c", "--stdout", "--to-stdout") {
return one(RiskReadOnly)
}
return []Risk{RiskWrite}, true, nil
case "git":
switch verb {
case "status", "log", "diff", "show", "branch", "rev-parse", "ls-files", "blame", "describe", "shortlog",
"grep", "cat-file", "ls-tree", "reflog", "config", "help", "version", "whatchanged", "tag", "remote":
if verb == "config" && !has("--get", "--list", "-l", "--get-all", "--get-regexp") ||
verb == "remote" && hasNonFlagAfterVerb(args, "remote") && !containsWord(args, "show", "-v", "get-url") ||
verb == "tag" && hasNonFlagAfterVerb(args, "tag") && !has("-l", "--list") ||
verb == "branch" && hasNonFlagAfterVerb(args, "branch") && !has("-l", "--list", "-a", "-r", "-v", "-vv") {
return []Risk{RiskWrite}, false, nil
}
return one(RiskReadOnly)
case "clone", "fetch", "pull", "push", "submodule":
return []Risk{RiskNetwork, RiskWrite}, false, nil
case "ls-remote":
return one(RiskNetwork)
}
return []Risk{RiskWrite}, false, nil
case "curl":
if has("-o", "--output", "-O", "--remote-name", "--remote-name-all", "-T", "--upload-file", "-c", "--cookie-jar", "-D", "--dump-header") {
return []Risk{RiskNetwork, RiskDownload, RiskWrite}, false, nil
}
return one(RiskNetwork)
case "wget", "aria2c":
if has("-O-", "--output-document=-", "--spider", "-qO-") || has("-O") && containsWord(args, "-") {
return one(RiskNetwork)
}
return []Risk{RiskNetwork, RiskDownload, RiskWrite}, false, nil
case "scp", "rsync":
r := []Risk{RiskWrite}
for _, a := range args {
if strings.Contains(a, ":") && !strings.HasPrefix(a, "-") {
r = append(r, RiskNetwork)
break
}
}
return r, prog == "scp", nil
case "openssl":
switch verb {
case "s_client", "s_time", "ocsp":
return one(RiskNetwork)
case "genrsa", "genpkey", "req", "ca", "rand", "enc", "pkcs12", "dhparam", "ecparam", "x509", "rsa", "ec", "pkey", "dgst", "smime", "cms":
if has("-out", "-keyout", "-new", "-newkey") {
return []Risk{RiskWrite}, false, nil
}
}
return one(RiskReadOnly)
case "nginx", "apache2ctl", "apachectl", "httpd":
if has("-t", "-T", "-v", "-V", "-S", "-M", "-l", "-L") || verb == "configtest" || verb == "-t" {
return one(RiskReadOnly)
}
return one(RiskService)
case "sshd":
if has("-t", "-T", "-G") {
return one(RiskReadOnly)
}
return one(RiskService)
case "visudo":
if has("-c", "--check") {
return one(RiskReadOnly)
}
return one(RiskAdmin)
case "docker", "podman", "nerdctl":
switch verb {
case "ps", "images", "inspect", "logs", "info", "version", "stats", "top", "port", "diff", "history", "events":
return one(RiskReadOnly)
case "rm", "rmi", "prune", "system":
return []Risk{RiskAdmin, RiskDelete}, false, nil
}
return []Risk{RiskAdmin, RiskNetwork}, false, nil
case "command":
if has("-v", "-V") {
return one(RiskReadOnly)
}
}
switch {
case readOnlyPrograms[prog]:
return one(RiskReadOnly)
case networkPrograms[prog]:
return one(RiskNetwork)
case destructivePrograms[prog] || strings.HasPrefix(prog, "mkfs."):
return []Risk{RiskDestructive}, true, nil
case adminPrograms[prog]:
return one(RiskAdmin)
case writePrograms[prog]:
return []Risk{RiskWrite}, false, nil
case prog == "eval" || prog == "source" || prog == "." || prog == "exec" || prog == "trap" || prog == "alias":
return one(RiskUnknown)
}
return one(RiskUnknown)
}
// unwrap desenvuelve comandos que ejecutan otro comando.
// Devuelve (nil, true) si es un envoltorio pero no se pudo extraer el comando.
func unwrap(prog string, args []string) ([]string, bool) {
switch prog {
case "sudo", "doas":
if hasAny(args, "-s", "--shell", "-i", "--login", "-e", "--edit", "-l", "--list", "-v", "--validate", "-k", "-K") {
if hasAny(args, "-l", "--list", "-v", "-k", "-K") && firstNonFlag(args) == "" {
return []string{"true"}, true
}
return nil, true
}
return orNil(skipFlags(args, "-u", "--user", "-g", "--group", "-C", "--close-from", "-h", "--host", "-p", "--prompt", "-r", "--role", "-t", "--type", "-U", "--other-user", "-D", "--chdir", "-T", "--command-timeout")), true
case "env":
rest := skipFlags(args, "-u", "--unset", "-C", "--chdir", "-S", "--split-string")
for len(rest) > 0 && assignmentRe.MatchString(rest[0]) {
rest = rest[1:]
}
if len(rest) == 0 {
return []string{"printenv"}, true
}
return rest, true
case "nice":
return orNil(skipFlags(args, "-n", "--adjustment")), true
case "ionice":
return orNil(skipFlags(args, "-c", "--class", "-n", "--classdata", "-p", "--pid", "-P", "--pgid", "-u", "--uid")), true
case "nohup", "setsid", "unbuffer", "builtin", "time", "chronic":
return orNil(skipFlags(args)), true
case "stdbuf":
return orNil(skipFlags(args, "-i", "-o", "-e")), true
case "timeout":
rest := skipFlags(args, "-s", "--signal", "-k", "--kill-after")
if len(rest) > 0 {
rest = rest[1:] // duración
}
return orNil(rest), true
case "watch":
return orNil(skipFlags(args, "-n", "--interval", "-d")), true
case "taskset":
rest := skipFlags(args, "-c")
if len(rest) > 0 && !hasAny(args, "-p", "--pid") {
rest = rest[1:] // máscara
}
return orNil(rest), true
case "chrt":
rest := skipFlags(args)
if len(rest) > 0 {
rest = rest[1:] // prioridad
}
return orNil(rest), true
case "busybox":
return orNil(args), true
case "su":
for i, a := range args {
if (a == "-c" || a == "--command") && i+1 < len(args) {
return []string{"sh", "-c", args[i+1]}, true
}
}
return nil, true
case "command":
if hasAny(args, "-v", "-V") {
return nil, false // lo gestiona programRisks
}
return orNil(skipFlags(args, "-p")), true
}
return nil, false
}
func orNil(s []string) []string {
if len(s) == 0 {
return nil
}
return s
}
func isShell(prog string) bool {
switch prog {
case "sh", "bash", "dash", "zsh", "ksh", "ash", "mksh", "fish", "rbash":
return true
}
return false
}
// shellDashC busca el script de "sh -c SCRIPT" (también -lc, -ec, ...).
func shellDashC(args []string) (string, bool) {
for i, a := range args {
if len(a) > 1 && a[0] == '-' && a[1] != '-' && strings.Contains(a, "c") {
if i+1 < len(args) {
return args[i+1], true
}
return "", false
}
if !strings.HasPrefix(a, "-") {
return "", false // sh script.sh: contenido no analizado
}
}
return "", false
}
func classifyFind(args []string, depth int) ([]Risk, bool, []Classification) {
risks := []Risk{RiskReadOnly}
var nested []Classification
for i := 0; i < len(args); i++ {
switch args[i] {
case "-delete":
risks = append(risks, RiskDelete)
case "-fprint", "-fprint0", "-fprintf", "-fls":
risks = append(risks, RiskWrite)
case "-exec", "-execdir", "-ok", "-okdir":
j := i + 1
for j < len(args) && args[j] != ";" && args[j] != "+" {
j++
}
nested = append(nested, classify(args[i+1:j], depth+1))
i = j
}
}
return risks, false, nested
}
func classifyIP(args []string) ([]Risk, bool, []Classification) {
var words []string
for i := 0; i < len(args); i++ {
a := args[i]
if strings.HasPrefix(a, "-") {
if a == "-n" || a == "-netns" || a == "-b" || a == "-batch" || a == "-rc" || a == "-rcvbuf" || a == "-f" || a == "-family" {
if a == "-b" || a == "-batch" {
return []Risk{RiskAdmin}, false, nil // ejecuta comandos de un archivo
}
i++
}
continue
}
words = append(words, a)
}
if len(words) < 2 {
return []Risk{RiskReadOnly}, false, nil
}
switch words[1] {
case "show", "list", "ls", "lst", "get", "help", "monitor", "sh", "s", "l":
return []Risk{RiskReadOnly}, false, nil
}
if words[0] == "netns" && (words[1] == "identify" || words[1] == "pids") {
return []Risk{RiskReadOnly}, false, nil
}
return []Risk{RiskAdmin}, false, nil
}
// firstNonFlag devuelve el primer argumento que no es una opción.
func firstNonFlag(args []string) string {
for _, a := range args {
if !strings.HasPrefix(a, "-") {
return a
}
}
return ""
}
// skipFlags elimina opciones iniciales; las de valueFlags consumen un valor.
func skipFlags(args []string, valueFlags ...string) []string {
vf := set(valueFlags...)
i := 0
for i < len(args) {
a := args[i]
if a == "--" {
return args[i+1:]
}
if !strings.HasPrefix(a, "-") || a == "-" {
break
}
if vf[a] {
i++
}
i++
}
if i > len(args) {
return nil
}
return args[i:]
}
func hasAny(args []string, flags ...string) bool {
for _, a := range args {
for _, f := range flags {
if a == f || strings.HasPrefix(f, "--") && strings.HasPrefix(a, f+"=") {
return true
}
}
}
return false
}
func containsWord(args []string, words ...string) bool {
return hasAny(args, words...)
}
// onlyFlags informa si todos los argumentos que son opciones pertenecen a
// allowed (los valores no-opción se permiten, p.ej. nombres de cadena).
func onlyFlags(args []string, allowed ...string) bool {
al := set(allowed...)
for _, a := range args {
if strings.HasPrefix(a, "-") {
// Opciones combinadas tipo -nvL
if len(a) > 2 && a[1] != '-' {
for _, ch := range a[1:] {
if !al["-"+string(ch)] {
return false
}
}
continue
}
if !al[a] {
return false
}
}
}
return true
}
// hasNonFlag indica si hay argumentos posicionales, ignorando los valores
// de las opciones valueFlags.
func hasNonFlag(args []string, valueFlags ...string) bool {
vf := set(valueFlags...)
for i := 0; i < len(args); i++ {
if vf[args[i]] {
i++
continue
}
if !strings.HasPrefix(args[i], "-") {
return true
}
}
return false
}
func hasNonFlagAfterVerb(args []string, verb string) bool {
seen := false
for _, a := range args {
if !seen {
seen = a == verb
continue
}
if !strings.HasPrefix(a, "-") {
return true
}
}
return false
}
+511
View File
@@ -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
}
+306
View File
@@ -0,0 +1,306 @@
package policy
import (
"os"
"path/filepath"
"strings"
"testing"
)
func argvs(s ShellScript) string {
var parts []string
for _, c := range s.Commands {
parts = append(parts, strings.Join(c.Argv, " "))
}
return strings.Join(parts, " | ")
}
func TestParseShell(t *testing.T) {
cases := []struct{ in, want string }{
{`ls -la /etc`, `ls -la /etc`},
{`cat a | grep "x y" && echo 'z;w'`, `cat a | grep x y | echo z;w`},
{`FOO=1 BAR=2 env`, `env`},
{`echo $(rm -rf /tmp/x)`, `rm -rf /tmp/x | echo $(...)`},
{"echo `whoami`", `whoami | echo $(...)`},
{`diff <(ls a) <(ls b)`, `ls a | ls b | diff <(...) <(...)`},
{`if systemctl is-active nginx; then echo ok; fi`, `systemctl is-active nginx | echo ok`},
{`for f in a b c; do rm "$f"; done`, `rm $f`},
{"cat <<'EOF' > /etc/x\n$(evil)\nEOF\nls", `cat | ls`},
{`echo $((1+2))`, `echo $((...))`},
{`# comment only`, ``},
{"a \\\n b", `a b`},
{`[[ -f /x && -d /y ]] && echo yes`, `[[ | echo yes`},
}
for _, c := range cases {
got := argvs(ParseShell(c.in))
if got != c.want {
t.Errorf("ParseShell(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseShellRedirects(t *testing.T) {
s := ParseShell(`echo hi > /etc/motd 2>&1; sort < in.txt >> out.txt; cmd &> /dev/null`)
var got []string
for _, c := range s.Commands {
for _, r := range c.Redirects {
got = append(got, r.Op+r.Target)
}
}
want := ">/etc/motd >&1 <in.txt >>out.txt &>/dev/null"
if strings.Join(got, " ") != want {
t.Errorf("redirects = %q, want %q", strings.Join(got, " "), want)
}
}
func TestParseShellHeredocUnquotedExpands(t *testing.T) {
s := ParseShell("cat <<EOF\n$(reboot)\nEOF\n")
if !strings.Contains(argvs(s), "reboot") {
t.Errorf("unquoted heredoc substitution not detected: %q", argvs(s))
}
}
func TestParseShellOpaque(t *testing.T) {
for _, in := range []string{`echo "unterminated`, `case $x in a) rm -rf /;; esac`, "echo `x"} {
if len(ParseShell(in).Opaque) == 0 {
t.Errorf("%q should be opaque", in)
}
}
}
func TestClassify(t *testing.T) {
cases := map[string]Risk{
"ls -la": RiskReadOnly,
"systemctl status nginx": RiskReadOnly,
"systemctl restart nginx": RiskService,
"systemctl reboot": RiskReboot,
"sudo systemctl restart nginx": RiskService,
"sudo -u postgres psql": RiskUnknown,
"apt list --installed": RiskReadOnly,
"apt-get install -y nginx": RiskPackage,
"dpkg -l nginx": RiskReadOnly,
"dpkg -i x.deb": RiskPackage,
"ip addr": RiskReadOnly,
"ip -br a show": RiskReadOnly,
"ip route add default via 1.2.3.4": RiskAdmin,
"nft list ruleset": RiskReadOnly,
"nft add rule inet f c drop": RiskAdmin,
"iptables -L -n -v": RiskReadOnly,
"iptables -A INPUT -j DROP": RiskAdmin,
"rm -rf /tmp/x": RiskDelete,
"dd if=/dev/zero of=/dev/sda": RiskDestructive,
"mkfs.ext4 /dev/sdb1": RiskDestructive,
"reboot": RiskReboot,
"shutdown -h now": RiskReboot,
"curl -s https://x": RiskNetwork,
"sed -n 1,10p f": RiskReadOnly,
"sed -i s/a/b/ f": RiskWrite,
"timeout 5 ping -c1 host": RiskNetwork,
"env A=1 rm x": RiskDelete,
"python3 -c print(1)": RiskUnknown,
"nginx -t": RiskReadOnly,
"nginx -s reload": RiskService,
"tar -tzf x.tgz": RiskReadOnly,
"tar -xzf x.tgz": RiskWrite,
"sysctl net.ipv4.ip_forward": RiskReadOnly,
"sysctl -w net.ipv4.ip_forward=1": RiskAdmin,
"useradd bob": RiskAdmin,
"userdel bob": RiskDestructive,
"somebinary --version": RiskReadOnly,
"somebinary --run": RiskUnknown,
"$CMD x": RiskUnknown,
"git status": RiskReadOnly,
"git clone https://x": RiskNetwork,
"journalctl -u nginx -n 50": RiskReadOnly,
"journalctl --vacuum-time=2d": RiskDelete,
}
for cmd, want := range cases {
c := Classify(strings.Fields(cmd))
found := false
for _, r := range c.AllRisks() {
if r == want {
found = true
}
}
if !found {
t.Errorf("Classify(%q) risks = %v, want to include %s", cmd, c.AllRisks(), want)
}
}
}
func TestClassifyNested(t *testing.T) {
c := Classify([]string{"bash", "-lc", "ls && reboot"})
if c.ReadOnly() {
t.Error("bash -c with reboot must not be read-only")
}
c = Classify([]string{"find", "/tmp", "-name", "*.log", "-exec", "rm", "{}", ";"})
if c.ReadOnly() {
t.Error("find -exec rm must not be read-only")
}
c = Classify([]string{"find", "/var/log", "-name", "*.gz"})
if !c.ReadOnly() {
t.Errorf("plain find should be read-only: %v", c.AllRisks())
}
}
// guardFor crea un proyecto temporal con la política dada.
func guardFor(t *testing.T, directives string) (*Guard, string) {
t.Helper()
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "agent.md"), []byte("x"), 0o644)
p, err := Parse("```policy\n" + directives + "\n```\n")
if err != nil {
t.Fatal(err)
}
g, err := NewGuard(p, dir, "agent.md")
if err != nil {
t.Fatal(err)
}
return g, dir
}
func TestGuardPaths(t *testing.T) {
g, dir := guardFor(t, "EXECUTION_MODE=privileged\nALLOW_OUTSIDE_PROJECT_READ=true\nALLOW_DELETE=true")
allowed := []struct {
op Op
p string
}{
{OpRead, "notes.txt"},
{OpWrite, "sub/file.txt"},
{OpWrite, "memoria.md"},
{OpRead, "/etc/hostname"},
{OpRead, "agent.md"},
{OpDelete, "sub/old.txt"},
}
for _, c := range allowed {
if err := g.CheckPath(c.op, c.p, ""); err != nil {
t.Errorf("%s %s: unexpected %v", c.op, c.p, err)
}
}
denied := []struct {
op Op
p string
}{
{OpWrite, "agent.md"},
{OpWrite, "init.conf"},
{OpRead, "init.conf"},
{OpWrite, "02-new-task.md"},
{OpWrite, "agent.log"},
{OpWrite, "/etc/motd"}, // ALLOW_OUTSIDE_PROJECT_WRITE no declarado
{OpDelete, "/"},
{OpDelete, "/etc"},
{OpDelete, "/etc/*"},
{OpDelete, dir},
{OpDelete, "*.md"},
}
for _, c := range denied {
err := g.CheckPath(c.op, c.p, "")
if !IsDenied(err) {
t.Errorf("%s %s: expected denial, got %v", c.op, c.p, err)
}
}
}
func TestGuardSymlinkEscape(t *testing.T) {
g, dir := guardFor(t, "EXECUTION_MODE=privileged")
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil {
t.Skip(err)
}
if err := g.CheckPath(OpWrite, "link/x.conf", ""); !IsDenied(err) {
t.Errorf("write through symlink to outside dir must be denied, got %v", err)
}
if err := g.CheckPath(OpRead, "link/x.conf", ""); !IsDenied(err) {
t.Errorf("read through symlink needs ALLOW_OUTSIDE_PROJECT_READ, got %v", err)
}
}
func TestGuardToolsDirNeedsCapability(t *testing.T) {
g, _ := guardFor(t, "EXECUTION_MODE=privileged")
if err := g.CheckPath(OpWrite, "tools/check.sh", ""); !IsDenied(err) {
t.Errorf("tools/ requires ALLOW_SELF_TOOL_CREATION: %v", err)
}
g2, _ := guardFor(t, "ALLOW_SELF_TOOL_CREATION=true")
if err := g2.CheckPath(OpWrite, "tools/check.sh", ""); err != nil {
t.Errorf("unexpected %v", err)
}
}
func TestGuardCommandsGuarded(t *testing.T) {
g, dir := guardFor(t, "EXECUTION_MODE=guarded\nALLOW_NETWORK=true\nALLOW_OUTSIDE_PROJECT_READ=true")
ok := [][]string{
{"ls", "-la", "/etc"},
{"systemctl", "status", "nginx"},
{"ping", "-c1", "example.com"},
{"mkdir", "-p", "build"}, // escritura dentro del proyecto
{"cat", "/etc/os-release"},
}
for _, argv := range ok {
if _, err := g.CheckArgv(argv, dir); err != nil {
t.Errorf("%v: unexpected %v", argv, err)
}
}
bad := [][]string{
{"systemctl", "restart", "nginx"},
{"apt-get", "install", "x"},
{"rm", "x"},
{"cp", "a", "/etc/b"},
{"useradd", "bob"},
{"python3", "script.py"},
{"reboot"},
{"cat", "init.conf"},
{"sed", "-i", "s/a/b/", "agent.md"},
}
for _, argv := range bad {
if _, err := g.CheckArgv(argv, dir); !IsDenied(err) {
t.Errorf("%v: expected denial, got %v", argv, err)
}
}
}
func TestGuardScripts(t *testing.T) {
g, dir := guardFor(t, "EXECUTION_MODE=privileged\nALLOW_OUTSIDE_PROJECT_READ=true\nALLOW_DELETE=true\nALLOW_SERVICE_RESTART=true")
ok := []string{
"journalctl -u nginx | tail -n 50",
"echo hello > out.txt",
"systemctl restart nginx && systemctl is-active nginx",
"ls /etc 2>&1 | grep ssh",
"rm -f build/*.o",
}
for _, s := range ok {
if _, err := g.CheckScript(s, dir); err != nil {
t.Errorf("%q: unexpected %v", s, err)
}
}
bad := []string{
"echo x > /etc/motd", // escritura fuera sin ALLOW_OUTSIDE_PROJECT_WRITE
"cd /etc && rm motd", // cd seguido: rm afecta a /etc/motd
"ls; reboot", // ALLOW_REBOOT no declarado
"echo $(shutdown -h now)", // sustitución
"bash -c 'dd if=/dev/zero of=/dev/sda'", // destructivo anidado
"rm -rf /",
"echo evil >> agent.md",
"cat init.conf",
"apt-get install -y x",
}
for _, s := range bad {
if _, err := g.CheckScript(s, dir); !IsDenied(err) {
t.Errorf("%q: expected denial, got %v", s, err)
}
}
}
func TestGuardUnrestrictedStillRespectsReboot(t *testing.T) {
g, dir := guardFor(t, "EXECUTION_MODE=unrestricted\nALLOW_DELETE=true\nALLOW_OUTSIDE_PROJECT_WRITE=true")
if _, err := g.CheckArgv([]string{"mkfs.ext4", "/dev/sdz9"}, dir); err != nil {
t.Errorf("unrestricted should allow mkfs: %v", err)
}
if _, err := g.CheckArgv([]string{"systemctl", "reboot"}, dir); !IsDenied(err) {
t.Errorf("ALLOW_REBOOT=false must block reboot even in unrestricted: %v", err)
}
if _, err := g.CheckArgv([]string{"rm", "-rf", "/usr"}, dir); !IsDenied(err) {
t.Errorf("critical path deletion must always be denied: %v", err)
}
}
+250
View File
@@ -0,0 +1,250 @@
package policy
import (
"bufio"
"fmt"
"strconv"
"strings"
"time"
)
// Parse extrae la política del contenido de agent.md.
//
// Por qué un bloque dedicado: agent.md es Markdown libre y puede contener
// ejemplos como "EXECUTION_MODE=unrestricted" dentro de la documentación.
// Si se interpretara cualquier línea KEY=VALUE, un ejemplo podría conceder
// permisos por accidente. Por eso solo se leen directivas dentro de un bloque
// de código cercado cuyo info string es exactamente "policy":
//
// ```policy
// EXECUTION_MODE=privileged
// ALLOW_NETWORK=true
// ```
//
// Reglas:
// - Sin bloque: política por defecto (guarded, nada permitido).
// - Más de un bloque: error, porque la intención sería ambigua.
// - Directiva repetida o valor inválido: error (no se adivina).
// - Directiva desconocida: se ignora y se reporta en Policy.Unknown.
func Parse(markdown string) (*Policy, error) {
block, found, err := extractPolicyBlock(markdown)
if err != nil {
return nil, err
}
p := Default()
if !found {
return p, nil
}
p.Declared = true
seen := map[string]int{}
sc := bufio.NewScanner(strings.NewReader(block.body))
lineNo := block.startLine
for sc.Scan() {
lineNo++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("agent.md line %d: expected KEY=VALUE in policy block", lineNo)
}
key = strings.ToUpper(strings.TrimSpace(key))
value = strings.TrimSpace(value)
if prev, dup := seen[key]; dup {
return nil, fmt.Errorf("agent.md line %d: duplicate directive %s (first on line %d)", lineNo, key, prev)
}
seen[key] = lineNo
if err := p.apply(key, value); err != nil {
return nil, fmt.Errorf("agent.md line %d: %w", lineNo, err)
}
}
p.sortedUnknown()
return p, nil
}
// apply interpreta una única directiva sobre la política.
func (p *Policy) apply(key, value string) error {
switch {
case key == directiveMode:
m := Mode(strings.ToLower(value))
switch m {
case ModeGuarded, ModePrivileged, ModeUnrestricted:
p.Mode = m
default:
return fmt.Errorf("invalid %s %q (want guarded, privileged or unrestricted)", key, value)
}
case key == directiveBackup:
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
p.BackupBeforeSystemEdit = b
case key == directiveTimeout:
n, err := strconv.Atoi(value)
if err != nil || n <= 0 {
return fmt.Errorf("%s must be a positive number of seconds, got %q", key, value)
}
p.DefaultCommandTimeout = time.Duration(n) * time.Second
case key == directiveMaxSteps:
n, err := strconv.Atoi(value)
if err != nil || n <= 0 || n > 1000 {
return fmt.Errorf("%s must be between 1 and 1000, got %q", key, value)
}
p.MaxSteps = n
case key == directiveMaxDL:
n, err := parseSize(value)
if err != nil || n <= 0 {
return fmt.Errorf("%s must be a positive size in bytes (suffixes K, M, G allowed), got %q", key, value)
}
p.MaxDownloadSize = n
case key == directiveTaskOrder:
for _, name := range strings.Split(value, ",") {
if name = strings.TrimSpace(name); name != "" {
p.TaskOrder = append(p.TaskOrder, name)
}
}
case knownCapabilities[Capability(key)]:
b, err := parseBool(value)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
p.caps[Capability(key)] = b
default:
p.Unknown = append(p.Unknown, key)
}
return nil
}
// parseBool acepta solo valores inequívocos. "yes"/"1" etc. se aceptan para
// comodidad, pero cualquier otra cosa es un error en lugar de un false
// silencioso, para que un error tipográfico no pase inadvertido.
func parseBool(v string) (bool, error) {
switch strings.ToLower(v) {
case "true", "yes", "1", "on":
return true, nil
case "false", "no", "0", "off":
return false, nil
}
return false, fmt.Errorf("invalid boolean %q", v)
}
type fencedBlock struct {
body string
startLine int // número de línea de la cerca de apertura
}
// extractPolicyBlock localiza el único bloque ```policy del documento.
//
// Se recorren todos los bloques cercados respetando su longitud de cerca
// (``` o más, o ~~~), de modo que un "```policy" que aparezca como texto
// dentro de otro bloque de ejemplo no se confunda con un bloque real.
func extractPolicyBlock(md string) (fencedBlock, bool, error) {
lines := strings.Split(md, "\n")
var (
result fencedBlock
found bool
inFence bool
fence string
isPolicy bool
body strings.Builder
openLine int
)
for i, raw := range lines {
line := strings.TrimRight(raw, "\r")
trimmed := strings.TrimLeft(line, " ")
indent := len(line) - len(trimmed)
if !inFence {
if indent > 3 {
continue
}
marker, info, ok := openingFence(trimmed)
if !ok {
continue
}
inFence, fence, openLine = true, marker, i+1
fields := strings.Fields(info)
isPolicy = len(fields) > 0 && strings.EqualFold(fields[0], "policy")
body.Reset()
continue
}
// Dentro de un bloque: ¿es la cerca de cierre?
if indent <= 3 && isClosingFence(trimmed, fence) {
if isPolicy {
if found {
return fencedBlock{}, false, fmt.Errorf("agent.md: multiple policy blocks (lines %d and %d); keep only one", result.startLine, openLine)
}
result = fencedBlock{body: body.String(), startLine: openLine}
found = true
}
inFence = false
continue
}
if isPolicy {
body.WriteString(line)
body.WriteByte('\n')
}
}
if inFence && isPolicy {
return fencedBlock{}, false, fmt.Errorf("agent.md line %d: unterminated policy block", openLine)
}
return result, found, nil
}
// openingFence detecta ``` o ~~~ (3 o más) y devuelve el marcador y el info string.
func openingFence(s string) (marker, info string, ok bool) {
if len(s) < 3 || (s[0] != '`' && s[0] != '~') {
return "", "", false
}
c := s[0]
n := 0
for n < len(s) && s[n] == c {
n++
}
if n < 3 {
return "", "", false
}
info = strings.TrimSpace(s[n:])
if c == '`' && strings.Contains(info, "`") {
return "", "", false
}
return s[:n], info, true
}
// isClosingFence: mismo carácter, al menos la misma longitud y nada más.
func isClosingFence(s, open string) bool {
s = strings.TrimRight(s, " \t")
if len(s) < len(open) {
return false
}
for i := 0; i < len(s); i++ {
if s[i] != open[0] {
return false
}
}
return true
}
// parseSize acepta bytes con sufijo opcional K, M o G (potencias de 1024).
func parseSize(v string) (int64, error) {
v = strings.ToUpper(strings.TrimSpace(v))
mult := int64(1)
for suffix, m := range map[string]int64{"K": 1 << 10, "M": 1 << 20, "G": 1 << 30, "KB": 1 << 10, "MB": 1 << 20, "GB": 1 << 30, "KIB": 1 << 10, "MIB": 1 << 20, "GIB": 1 << 30} {
if strings.HasSuffix(v, suffix) && len(suffix) > 0 {
if _, err := strconv.ParseInt(strings.TrimSuffix(v, suffix), 10, 64); err == nil {
mult = m
v = strings.TrimSuffix(v, suffix)
break
}
}
}
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0, err
}
return n * mult, nil
}
+149
View File
@@ -0,0 +1,149 @@
package policy
import (
"strings"
"testing"
"time"
)
func TestNoBlockGivesDefaults(t *testing.T) {
// Ejemplos sueltos en el texto NO deben conceder permisos.
md := "# Agente\n\nEjemplo: EXECUTION_MODE=unrestricted\n\n```text\nALLOW_DELETE=true\n```\n"
p, err := Parse(md)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.Declared {
t.Error("Declared should be false")
}
if p.Mode != ModeGuarded {
t.Errorf("Mode = %s, want guarded", p.Mode)
}
for _, c := range AllCapabilities {
if p.Allows(c) {
t.Errorf("%s must default to false", c)
}
}
if !p.BackupBeforeSystemEdit {
t.Error("BACKUP_BEFORE_SYSTEM_EDIT must default to true")
}
if p.DefaultCommandTimeout != 60*time.Second {
t.Errorf("timeout = %v", p.DefaultCommandTimeout)
}
}
func TestPolicyBlock(t *testing.T) {
md := strings.Join([]string{
"# Identidad",
"",
"```policy",
"# comentario",
"EXECUTION_MODE=privileged",
"ALLOW_NETWORK=true",
"ALLOW_DELETE=false",
"BACKUP_BEFORE_SYSTEM_EDIT=false",
"DEFAULT_COMMAND_TIMEOUT=120",
"TASK_ORDER=02-b.md, 01-a.md",
"SOMETHING_NEW=1",
"```",
"",
}, "\n")
p, err := Parse(md)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if !p.Declared || p.Mode != ModePrivileged {
t.Fatalf("Declared=%v Mode=%s", p.Declared, p.Mode)
}
if !p.Allows(CapNetwork) || p.Allows(CapDelete) {
t.Error("capabilities not applied correctly")
}
if p.Allows(CapReboot) {
t.Error("undeclared ALLOW_REBOOT must be false")
}
if p.BackupBeforeSystemEdit {
t.Error("backup should be false")
}
if p.DefaultCommandTimeout != 120*time.Second {
t.Errorf("timeout = %v", p.DefaultCommandTimeout)
}
if strings.Join(p.TaskOrder, "|") != "02-b.md|01-a.md" {
t.Errorf("TaskOrder = %v", p.TaskOrder)
}
if len(p.Unknown) != 1 || p.Unknown[0] != "SOMETHING_NEW" {
t.Errorf("Unknown = %v", p.Unknown)
}
}
func TestUnrestrictedOnlyWhenExplicit(t *testing.T) {
p, err := Parse("```policy\nEXECUTION_MODE=unrestricted\n```\n")
if err != nil {
t.Fatal(err)
}
if p.Mode != ModeUnrestricted {
t.Errorf("Mode = %s", p.Mode)
}
// Incluso unrestricted no concede capacidades no declaradas.
if p.Allows(CapReboot) {
t.Error("unrestricted must not imply ALLOW_REBOOT")
}
}
func TestPolicyInsideOtherFenceIsIgnored(t *testing.T) {
// Un "```policy" dentro de un bloque ```` (4 backticks) es solo texto.
md := "````markdown\n```policy\nEXECUTION_MODE=unrestricted\n```\n````\n"
p, err := Parse(md)
if err != nil {
t.Fatal(err)
}
if p.Declared || p.Mode != ModeGuarded {
t.Errorf("nested example must be ignored: Declared=%v Mode=%s", p.Declared, p.Mode)
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"two blocks": "```policy\nALLOW_NETWORK=true\n```\n```policy\nALLOW_DELETE=true\n```\n",
"bad mode": "```policy\nEXECUTION_MODE=god\n```\n",
"bad bool": "```policy\nALLOW_DELETE=maybe\n```\n",
"duplicate": "```policy\nALLOW_DELETE=false\nALLOW_DELETE=true\n```\n",
"no equals": "```policy\nALLOW_DELETE\n```\n",
"bad timeout": "```policy\nDEFAULT_COMMAND_TIMEOUT=0\n```\n",
"unterminated": "```policy\nALLOW_DELETE=true\n",
}
for name, md := range cases {
if _, err := Parse(md); err == nil {
t.Errorf("%s: expected error", name)
}
}
}
func TestMaxSteps(t *testing.T) {
p, _ := Parse("")
if p.MaxSteps != 40 {
t.Errorf("default MaxSteps = %d", p.MaxSteps)
}
p, err := Parse("```policy\nMAX_STEPS=12\n```\n")
if err != nil || p.MaxSteps != 12 {
t.Fatalf("%v %v", p, err)
}
if _, err := Parse("```policy\nMAX_STEPS=0\n```\n"); err == nil {
t.Error("MAX_STEPS=0 must fail")
}
}
func TestMaxDownloadSize(t *testing.T) {
p, _ := Parse("")
if p.MaxDownloadSize != 1<<30 {
t.Errorf("default = %d", p.MaxDownloadSize)
}
for in, want := range map[string]int64{"1048576": 1 << 20, "512M": 512 << 20, "2G": 2 << 30, "10KB": 10 << 10} {
p, err := Parse("```policy\nMAX_DOWNLOAD_SIZE=" + in + "\n```\n")
if err != nil || p.MaxDownloadSize != want {
t.Errorf("%s: %d %v", in, p.MaxDownloadSize, err)
}
}
if _, err := Parse("```policy\nMAX_DOWNLOAD_SIZE=lots\n```\n"); err == nil {
t.Error("invalid size must fail")
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package policy modela la política de ejecución declarada en agent.md.
//
// La política decide qué puede hacer el agente. Su principio central es
// "fail closed": toda capacidad peligrosa que no esté declarada explícitamente
// se considera denegada, y el modo unrestricted solo se activa cuando
// agent.md lo pide literalmente.
package policy
import (
"fmt"
"sort"
"strings"
"time"
)
// Mode es el nivel de autoridad general del agente.
type Mode string
const (
// ModeGuarded permite lectura, análisis y diagnóstico. Es el valor por defecto.
ModeGuarded Mode = "guarded"
// ModePrivileged permite administración activa dentro de las directivas.
ModePrivileged Mode = "privileged"
// ModeUnrestricted permite operaciones destructivas necesarias para la tarea.
ModeUnrestricted Mode = "unrestricted"
)
// Capability identifica una capacidad controlada por una directiva ALLOW_*.
type Capability string
const (
CapNetwork Capability = "ALLOW_NETWORK"
CapWebResearch Capability = "ALLOW_WEB_RESEARCH"
CapDownloads Capability = "ALLOW_DOWNLOADS"
CapPackageInstall Capability = "ALLOW_PACKAGE_INSTALL"
CapServiceRestart Capability = "ALLOW_SERVICE_RESTART"
CapOutsideRead Capability = "ALLOW_OUTSIDE_PROJECT_READ"
CapOutsideWrite Capability = "ALLOW_OUTSIDE_PROJECT_WRITE"
CapDelete Capability = "ALLOW_DELETE"
CapReboot Capability = "ALLOW_REBOOT"
CapSelfToolCreation Capability = "ALLOW_SELF_TOOL_CREATION"
)
// Directivas que no son capacidades booleanas ALLOW_*.
const (
directiveMode = "EXECUTION_MODE"
directiveBackup = "BACKUP_BEFORE_SYSTEM_EDIT"
directiveTimeout = "DEFAULT_COMMAND_TIMEOUT"
directiveTaskOrder = "TASK_ORDER"
directiveMaxSteps = "MAX_STEPS"
directiveMaxDL = "MAX_DOWNLOAD_SIZE"
)
const (
defaultCommandTimeout = 60 * time.Second
defaultMaxSteps = 40
defaultMaxDownload = 1 << 30 // 1 GiB (§68)
)
// AllCapabilities lista las capacidades en un orden estable para mostrarlas.
var AllCapabilities = []Capability{
CapNetwork, CapWebResearch, CapDownloads, CapPackageInstall,
CapServiceRestart, CapOutsideRead, CapOutsideWrite, CapDelete,
CapReboot, CapSelfToolCreation,
}
// Policy es la política efectiva tras interpretar agent.md.
type Policy struct {
Mode Mode
// caps contiene solo las capacidades concedidas explícitamente.
caps map[Capability]bool
// BackupBeforeSystemEdit es protector, no peligroso, por eso su valor por
// defecto es true: omitirlo nunca debe reducir la seguridad.
BackupBeforeSystemEdit bool
// DefaultCommandTimeout es el timeout de los comandos sin timeout propio.
DefaultCommandTimeout time.Duration
// MaxSteps limita las llamadas al modelo por tarea o por mensaje del CLI,
// para que un agente atascado no itere indefinidamente.
MaxSteps int
// MaxDownloadSize limita el tamaño de cada descarga en bytes (§68).
MaxDownloadSize int64
// TaskOrder es el orden explícito de tareas si agent.md lo define.
// Vacío significa orden lexicográfico.
TaskOrder []string
// Unknown son directivas no reconocidas; se reportan en el log.
Unknown []string
// Declared indica si agent.md contenía un bloque de política. Si es false,
// se aplicaron todos los valores por defecto.
Declared bool
}
// Default devuelve la política más restrictiva: guarded y sin capacidades.
func Default() *Policy {
return &Policy{
Mode: ModeGuarded,
caps: map[Capability]bool{},
BackupBeforeSystemEdit: true,
DefaultCommandTimeout: defaultCommandTimeout,
MaxSteps: defaultMaxSteps,
MaxDownloadSize: defaultMaxDownload,
}
}
// Allows informa si una capacidad fue concedida explícitamente.
func (p *Policy) Allows(c Capability) bool {
return p.caps[c]
}
// Summary produce una representación legible, estable y sin secretos,
// usada por /policy y en el prompt de sistema.
func (p *Policy) Summary() string {
var b strings.Builder
fmt.Fprintf(&b, "EXECUTION_MODE=%s\n", p.Mode)
for _, c := range AllCapabilities {
fmt.Fprintf(&b, "%s=%t\n", c, p.Allows(c))
}
fmt.Fprintf(&b, "%s=%t\n", directiveBackup, p.BackupBeforeSystemEdit)
fmt.Fprintf(&b, "%s=%d\n", directiveTimeout, int(p.DefaultCommandTimeout/time.Second))
fmt.Fprintf(&b, "%s=%d\n", directiveMaxSteps, p.MaxSteps)
fmt.Fprintf(&b, "%s=%d\n", directiveMaxDL, p.MaxDownloadSize)
if len(p.TaskOrder) > 0 {
fmt.Fprintf(&b, "%s=%s\n", directiveTaskOrder, strings.Join(p.TaskOrder, ","))
}
if !p.Declared {
b.WriteString("# no policy block found in agent.md: defaults applied\n")
}
return b.String()
}
// knownCapabilities permite validar nombres ALLOW_* rápidamente.
var knownCapabilities = func() map[Capability]bool {
m := make(map[Capability]bool, len(AllCapabilities))
for _, c := range AllCapabilities {
m[c] = true
}
return m
}()
// sortedUnknown ordena las directivas desconocidas para logs deterministas.
func (p *Policy) sortedUnknown() {
sort.Strings(p.Unknown)
}
+513
View File
@@ -0,0 +1,513 @@
package policy
import (
"regexp"
"strings"
)
// ShellCommand es un comando simple extraído de una expresión de shell.
type ShellCommand struct {
Argv []string
Redirects []Redirect
}
// Redirect es una redirección de E/S (">", ">>", "<", ...).
type Redirect struct {
Op string
Target string
}
// Writes indica si la redirección escribe en su destino.
func (r Redirect) Writes() bool {
return strings.Contains(r.Op, ">")
}
// ShellScript es el resultado del análisis de un script.
//
// Opaque recoge construcciones que el analizador no puede evaluar con
// seguridad (case, eval dinámico, comillas sin cerrar...). Quien decide
// la política debe tratarlas como riesgo desconocido.
type ShellScript struct {
Commands []ShellCommand
Opaque []string
}
// ParseShell analiza una expresión de shell tipo POSIX/bash de forma
// aproximada y conservadora.
//
// Por qué existe: la herramienta shell ejecuta texto arbitrario y la política
// necesita saber qué programas se invocarán y qué archivos se redirigen.
// No es un intérprete completo: su objetivo es encontrar todos los comandos
// que se ejecutarían, incluidos los de sustituciones $(...), `...`, <(...) y
// bash -c, y marcar como opaco lo que no pueda entender.
//
// Limitación importante: es una barrera contra errores del modelo, no un
// sandbox frente a un adversario. Ver README (Seguridad).
func ParseShell(src string) ShellScript {
var out ShellScript
parseShellInto(src, 0, &out)
return out
}
const maxShellDepth = 8
func parseShellInto(src string, depth int, out *ShellScript) {
if depth > maxShellDepth {
out.Opaque = append(out.Opaque, "nesting too deep")
return
}
lx := &lexer{src: []rune(src)}
toks := lx.run()
out.Opaque = append(out.Opaque, lx.opaque...)
for _, sub := range lx.subs {
parseShellInto(sub, depth+1, out)
}
buildCommands(toks, out)
}
type tokKind int
const (
tokWord tokKind = iota
tokSep
tokRedir
)
type token struct {
kind tokKind
text string
target string // solo para tokRedir
}
type lexer struct {
src []rune
i int
subs []string // cuerpos de sustituciones de comandos a analizar
opaque []string
heredocs []heredoc
}
type heredoc struct {
delim string
quoted bool
strip bool // <<- elimina tabuladores iniciales
}
func (l *lexer) peek(off int) rune {
if l.i+off < len(l.src) {
return l.src[l.i+off]
}
return 0
}
func (l *lexer) run() []token {
var toks []token
for l.i < len(l.src) {
c := l.src[l.i]
switch {
case c == ' ' || c == '\t' || c == '\r':
l.i++
case c == '\\' && l.peek(1) == '\n':
l.i += 2
case c == '#':
for l.i < len(l.src) && l.src[l.i] != '\n' {
l.i++
}
case c == '\n':
l.i++
toks = append(toks, token{kind: tokSep, text: "\n"})
l.readHeredocs()
case c == ';' || c == '&' || c == '|' || c == '(' || c == ')':
// Redirección &> / &>>
if c == '&' && l.peek(1) == '>' {
toks = append(toks, l.readRedirect())
continue
}
op := string(c)
if n := l.peek(1); (c == '&' && n == '&') || (c == '|' && (n == '|' || n == '&')) || (c == ';' && n == ';') {
op += string(n)
}
l.i += len([]rune(op))
toks = append(toks, token{kind: tokSep, text: op})
case c == '<' || c == '>' || (isDigit(c) && l.redirAfterDigits()):
if (c == '<' || c == '>') && l.peek(1) == '(' {
// Sustitución de proceso <(...) / >(...)
l.i++
body, ok := l.balanced()
if !ok {
l.opaque = append(l.opaque, "unterminated process substitution")
}
l.subs = append(l.subs, body)
toks = append(toks, token{kind: tokWord, text: "<(...)"})
continue
}
toks = append(toks, l.readRedirect())
default:
toks = append(toks, token{kind: tokWord, text: l.readWord()})
}
}
if len(l.heredocs) > 0 {
l.opaque = append(l.opaque, "unterminated heredoc")
}
return toks
}
// redirAfterDigits detecta "2>" o "10<" (descriptor explícito). Solo se
// invoca al inicio de un token, así que "a2>b" no llega aquí.
func (l *lexer) redirAfterDigits() bool {
j := l.i
for j < len(l.src) && isDigit(l.src[j]) {
j++
}
return j < len(l.src) && (l.src[j] == '<' || l.src[j] == '>')
}
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
func isWordEnd(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';' || r == '&' || r == '|' || r == '(' || r == ')' || r == '<' || r == '>'
}
// readRedirect lee un operador de redirección y su destino.
func (l *lexer) readRedirect() token {
for l.i < len(l.src) && isDigit(l.src[l.i]) {
l.i++
}
var op strings.Builder
for l.i < len(l.src) && strings.ContainsRune("<>&|-", l.src[l.i]) && op.Len() < 3 {
// "-" solo forma parte del operador en "<<-"
if l.src[l.i] == '-' && op.String() != "<<" {
break
}
// "|" solo en ">|"
if l.src[l.i] == '|' && op.String() != ">" {
break
}
op.WriteRune(l.src[l.i])
l.i++
}
opText := op.String()
for l.i < len(l.src) && (l.src[l.i] == ' ' || l.src[l.i] == '\t') {
l.i++
}
quoted := l.i < len(l.src) && (l.src[l.i] == '\'' || l.src[l.i] == '"' || l.src[l.i] == '\\')
target := l.readWord()
switch opText {
case "<<", "<<-":
l.heredocs = append(l.heredocs, heredoc{delim: target, quoted: quoted, strip: opText == "<<-"})
}
return token{kind: tokRedir, text: opText, target: target}
}
// readHeredocs consume los cuerpos de heredoc pendientes tras un salto de línea.
// El cuerpo es dato, no comandos; pero si el delimitador no está entre
// comillas, el shell expande $(...) dentro del cuerpo, así que se analiza.
func (l *lexer) readHeredocs() {
for _, h := range l.heredocs {
var body strings.Builder
found := false
for l.i < len(l.src) {
end := l.i
for end < len(l.src) && l.src[end] != '\n' {
end++
}
line := string(l.src[l.i:end])
l.i = end
if l.i < len(l.src) {
l.i++
}
check := line
if h.strip {
check = strings.TrimLeft(line, "\t")
}
if check == h.delim {
found = true
break
}
body.WriteString(line)
body.WriteByte('\n')
}
if !found {
l.opaque = append(l.opaque, "unterminated heredoc")
}
if !h.quoted {
sub := &lexer{src: []rune(body.String())}
sub.scanSubstitutionsOnly()
l.subs = append(l.subs, sub.subs...)
l.opaque = append(l.opaque, sub.opaque...)
}
}
l.heredocs = nil
}
// scanSubstitutionsOnly busca $(...) y `...` en texto que no es código.
func (l *lexer) scanSubstitutionsOnly() {
for l.i < len(l.src) {
c := l.src[l.i]
switch {
case c == '\\':
l.i += 2
case c == '$' && l.peek(1) == '(':
l.i++
l.dollarParen(nil)
case c == '`':
l.backtick(nil)
default:
l.i++
}
}
}
// readWord lee una palabra eliminando comillas y registrando sustituciones.
func (l *lexer) readWord() string {
var w strings.Builder
for l.i < len(l.src) {
c := l.src[l.i]
if isWordEnd(c) {
break
}
switch c {
case '\\':
if l.i+1 < len(l.src) {
w.WriteRune(l.src[l.i+1])
}
l.i += 2
case '\'':
l.i++
end := l.i
for end < len(l.src) && l.src[end] != '\'' {
end++
}
if end >= len(l.src) {
l.opaque = append(l.opaque, "unterminated single quote")
}
w.WriteString(string(l.src[l.i:min(end, len(l.src))]))
l.i = end + 1
case '"':
l.i++
l.doubleQuoted(&w)
case '$':
switch l.peek(1) {
case '(':
l.i++
l.dollarParen(&w)
case '{':
// Expansión de parámetro ${...}: se copia tal cual.
end := l.i + 2
depth := 1
for end < len(l.src) && depth > 0 {
if l.src[end] == '{' {
depth++
} else if l.src[end] == '}' {
depth--
}
end++
}
w.WriteString(string(l.src[l.i:end]))
l.i = end
default:
w.WriteRune(c)
l.i++
}
case '`':
l.backtick(&w)
default:
w.WriteRune(c)
l.i++
}
}
return w.String()
}
func (l *lexer) doubleQuoted(w *strings.Builder) {
for l.i < len(l.src) {
c := l.src[l.i]
switch {
case c == '"':
l.i++
return
case c == '\\' && l.i+1 < len(l.src):
w.WriteRune(l.src[l.i+1])
l.i += 2
case c == '$' && l.peek(1) == '(':
l.i++
l.dollarParen(w)
case c == '`':
l.backtick(w)
default:
w.WriteRune(c)
l.i++
}
}
l.opaque = append(l.opaque, "unterminated double quote")
}
// dollarParen se llama con l.i apuntando a '(' tras '$'.
// Distingue $((aritmética)) de $(comando).
func (l *lexer) dollarParen(w *strings.Builder) {
if l.peek(1) == '(' {
// Aritmética: se omite, pero puede contener $(...) anidado.
body, ok := l.balanced()
if !ok {
l.opaque = append(l.opaque, "unterminated arithmetic expansion")
}
sub := &lexer{src: []rune(body)}
sub.scanSubstitutionsOnly()
l.subs = append(l.subs, sub.subs...)
if w != nil {
w.WriteString("$((...))")
}
return
}
body, ok := l.balanced()
if !ok {
l.opaque = append(l.opaque, "unterminated command substitution")
}
l.subs = append(l.subs, body)
if w != nil {
w.WriteString("$(...)")
}
}
// backtick extrae `...`.
func (l *lexer) backtick(w *strings.Builder) {
l.i++
var body strings.Builder
for l.i < len(l.src) && l.src[l.i] != '`' {
if l.src[l.i] == '\\' && l.i+1 < len(l.src) {
body.WriteRune(l.src[l.i+1])
l.i += 2
continue
}
body.WriteRune(l.src[l.i])
l.i++
}
if l.i >= len(l.src) {
l.opaque = append(l.opaque, "unterminated backtick")
}
l.i++
l.subs = append(l.subs, body.String())
if w != nil {
w.WriteString("$(...)")
}
}
// balanced lee desde '(' hasta su ')' correspondiente, respetando comillas.
// Devuelve el contenido interior.
func (l *lexer) balanced() (string, bool) {
l.i++ // salta '('
start := l.i
depth := 1
for l.i < len(l.src) {
c := l.src[l.i]
switch c {
case '\\':
l.i += 2
continue
case '\'':
l.i++
for l.i < len(l.src) && l.src[l.i] != '\'' {
l.i++
}
case '"':
l.i++
for l.i < len(l.src) && l.src[l.i] != '"' {
if l.src[l.i] == '\\' {
l.i++
}
l.i++
}
case '(':
depth++
case ')':
depth--
if depth == 0 {
body := string(l.src[start:l.i])
l.i++
return body, true
}
}
l.i++
}
return string(l.src[start:min(l.i, len(l.src))]), false
}
var assignmentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=`)
// buildCommands agrupa tokens en comandos simples, saltando palabras
// reservadas y asignaciones iniciales.
func buildCommands(toks []token, out *ShellScript) {
var cur ShellCommand
skipUntilSep := false // tras "for x in ...": la lista no son comandos
inCase := false
inDoubleBracket := false
flush := func() {
if len(cur.Argv) > 0 || len(cur.Redirects) > 0 {
out.Commands = append(out.Commands, cur)
}
cur = ShellCommand{}
}
for _, t := range toks {
switch t.kind {
case tokSep:
if inDoubleBracket {
continue // && y || dentro de [[ ]]
}
skipUntilSep = false
flush()
continue
case tokRedir:
cur.Redirects = append(cur.Redirects, Redirect{Op: t.text, Target: t.target})
continue
}
w := t.text
if inCase {
if w == "esac" {
inCase = false
}
continue
}
if inDoubleBracket {
if w == "]]" {
inDoubleBracket = false
}
continue
}
if skipUntilSep {
continue
}
if len(cur.Argv) == 0 {
switch w {
case "if", "then", "elif", "else", "do", "while", "until", "!", "time", "{", "}", "fi", "done", "coproc":
continue
case "for", "select":
skipUntilSep = true
continue
case "case":
out.Opaque = append(out.Opaque, "case statement")
inCase = true
continue
case "function":
skipUntilSep = false
continue
case "[[":
cur.Argv = append(cur.Argv, "[[")
inDoubleBracket = true
continue
}
if assignmentRe.MatchString(w) {
continue
}
// Definición "nombre()": el cuerpo se analiza como comandos normales.
if strings.HasSuffix(w, "()") {
continue
}
}
cur.Argv = append(cur.Argv, w)
}
flush()
}