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 or piping a remote script straight into a root shell regexp.MustCompile(`(?i)\bcurl\b.*\|\s*(sudo\s+)?(ba)?sh\b`), regexp.MustCompile(`(?i)\bwget\b.*\|\s*(sudo\s+)?(ba)?sh\b`), 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|` + `systemctl\s+(status|is-active|is-enabled|is-failed|list-units)|` + `docker\s+(ps|images|inspect|logs|version|info)|` + `pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` + `git\s+(status|log|diff|show|branch|remote)|` + `curl\s+-.*-I\b|curl\s+.*--head\b)\b`) // compoundOpPattern matches shell operators that chain or substitute // commands. A "read-only lead verb" only qualifies a command for the // read_only fast path when the WHOLE command is simple — otherwise a // compound like "cat file && rm -rf /" would slip through on its first verb. 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 } } if !compoundOpPattern.MatchString(cmd) { // Strip a leading sudo/env assignment so "sudo cat /x" still matches. probe := cmd probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "") probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "") if readOnlyLeadPattern.MatchString(probe) { return RiskReadOnly } } // Not obviously destructive, not a recognized read-only inspection — // default to the gated tier rather than guessing it's safe. return RiskConfigMutation }