post inicial
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user