package policy import ( "regexp" "strings" ) // Risk class names, in escalation order (index = severity). A command's final // risk class is the MAX of what the rules compute and what the caller // declared — classification can only escalate, never de-escalate, mirroring // the signal classifier's "policy can only lower autonomy, never raise it." const ( RiskReadOnly = "read_only" RiskReversibleLow = "reversible_low" RiskConfigMutation = "config_mutation" RiskDestructive = "destructive" ) var riskOrder = map[string]int{ RiskReadOnly: 0, RiskReversibleLow: 1, RiskConfigMutation: 2, RiskDestructive: 3, } func riskRank(r string) int { if n, ok := riskOrder[r]; ok { return n } return riskOrder[RiskConfigMutation] // unknown declared risk: assume the safer-to-gate default } // destructivePatterns match commands that must always be treated as // destructive, regardless of what the caller declares. Irreversible, // data-loss, or fleet-wide-impact operations. Matched against the raw // command text, case-insensitive. var destructivePatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)\brm\s+.*-[a-zA-Z]*r[a-zA-Z]*f|\brm\s+.*-[a-zA-Z]*f[a-zA-Z]*r`), // rm -rf / rm -fr (any flag order) regexp.MustCompile(`(?i)\bdd\s+.*of=`), regexp.MustCompile(`(?i)\bmkfs(\.\w+)?\b`), regexp.MustCompile(`(?i)\bwipefs\b`), regexp.MustCompile(`(?i)\bshred\b`), regexp.MustCompile(`(?i)\bpct\s+destroy\b`), regexp.MustCompile(`(?i)\bqm\s+destroy\b`), regexp.MustCompile(`(?i)\bzpool\s+destroy\b`), regexp.MustCompile(`(?i)\blvremove\b|\bvgremove\b|\bpvremove\b`), regexp.MustCompile(`(?i)\bdrop\s+(table|database|schema)\b`), regexp.MustCompile(`(?i)\btruncate\s+table\b`), regexp.MustCompile(`(?i)>\s*/dev/(sd|nvme|vd|hd)`), regexp.MustCompile(`(?i)\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b`), regexp.MustCompile(`(?i)\bformat\b.*\b(disk|partition|volume)\b`), regexp.MustCompile(`:\(\)\s*\{.*:\|:.*\}\s*;\s*:`), // fork bomb regexp.MustCompile(`(?i)\bchmod\s+-R\s+000\b|\bchmod\s+000\s+/`), regexp.MustCompile(`(?i)\biptables\s+-F\b|\bufw\s+disable\b`), // wipes firewall // secret/credential exfiltration — reading private keys, shadow, or age // keys is always destructive. (Piping a remote script into a shell via // curl|sh was previously here too, but that pattern is common for // legitimate installs — get.docker.com, convenience scripts — and // demoting it to config_mutation means loose assent can grant it without // a typed confirmation. The assent window covers the deploy case.) regexp.MustCompile(`(?i)\bcat\s+.*(id_rsa|id_ed25519|\.pem|shadow|\.age)\b`), } // readOnlyLeadPattern matches the leading command word (after env-var // prefixes and a leading sudo) against a small allowlist of verbs that are // safe to auto-run unattended: they inspect state and cannot mutate it. // Compound commands (&&, ;, |, $(), backticks) are excluded from this fast // path below — only a single simple command can qualify. var readOnlyLeadPattern = regexp.MustCompile( `^(cat|less|head|tail|ls|stat|file|du|df|free|uptime|uname|hostname|whoami|id|ip|ss|netstat|ping|` + `journalctl|dmesg|ps|top|htop|env|printenv|echo|which|whereis|` + `grep|egrep|fgrep|rg|wc|sort|uniq|cut|tr|tee|find|tree|locate|` + `dpkg\s+(-l|-s|--list|--status)\b|apt\s+(list|search|show)\b|` + `systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` + `timedatectl|hostnamectl|systemd-analyze|` + `docker\s+(ps|images|inspect|logs|version|info|stats)|` + `docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` + `pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` + `rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` + `git\s+(status|log|diff|show|branch|remote)|` + `curl\s+-.*-I\b|curl\s+.*--head\b)\b`) // compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |) // so each segment can be individually classified. A piped or chained command // where EVERY segment is a recognized read-only inspection verb is safe to // auto-run — e.g. "systemctl status caddy; journalctl -u caddy -n 5" or // "docker ps | grep caddy". var compoundSplitRe = regexp.MustCompile(`\s*(?:&&|\|\||;|\|)\s*`) // subshellRe matches command substitution ($() or backticks) that can hide // arbitrary execution. A command using these never qualifies for the read-only // fast path — the substituted content could do anything. var subshellRe = regexp.MustCompile("\\$\\(|`") // compoundOpPattern is retained for compatibility — matches any compound // operator. (Previously used to block ALL compound commands from the read-only // path; now the per-segment check is more precise.) var compoundOpPattern = regexp.MustCompile("[;&|`]|\\$\\(") // ClassifyCommand scores an arbitrary shell command for the general `run` // primitive. It combines a rule-based verdict (destructive denylist first, // then a read-only allowlist for simple inspection commands) with the // caller's declared risk, and returns the more severe of the two — the // classifier may only escalate, never de-escalate, so a model that // under-declares risk (or an adversarial prompt) cannot talk its way past a // genuinely dangerous command. Anything not matched by either rule defaults // to config_mutation (escalate), per "when in doubt, escalate." func ClassifyCommand(command, declaredRisk string) string { computed := computeCommandRisk(command) if declaredRisk == "" { return computed // no declaration to escalate with; computed's own escalate-by-default already applies } declared := normalizeRisk(declaredRisk) if riskRank(declared) > riskRank(computed) { return declared } return computed } func normalizeRisk(r string) string { if _, ok := riskOrder[r]; ok { return r } return RiskConfigMutation } func computeCommandRisk(command string) string { cmd := strings.TrimSpace(command) if cmd == "" { return RiskConfigMutation } for _, p := range destructivePatterns { if p.MatchString(cmd) { return RiskDestructive } } // Subshell substitution ($(), backticks) can hide arbitrary execution — // never auto-run, even if the visible verbs look read-only. if !subshellRe.MatchString(cmd) { if allSegmentsReadOnly(cmd) { return RiskReadOnly } } // Not obviously destructive, not a recognized read-only inspection — // default to the gated tier rather than guessing it's safe. return RiskConfigMutation } // allSegmentsReadOnly splits a compound command on chaining operators // (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only // inspection verb. If so, the whole command is safe to auto-run. Any segment // that isn't a recognized read-only verb disqualifies the whole command — // the classifier errs toward gating, not guessing. func allSegmentsReadOnly(cmd string) bool { segments := compoundSplitRe.Split(cmd, -1) for _, seg := range segments { seg = strings.TrimSpace(seg) if seg == "" { continue } // Strip a leading sudo/env assignment so "sudo cat /x" still matches. probe := seg probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "") probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "") probe = strings.TrimSpace(probe) if !readOnlyLeadPattern.MatchString(probe) { return false } } return len(segments) > 0 }