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))\b`) // envAssignRe matches leading FOO=bar env-var assignments so they can be // stripped before the read-only verb check. var envAssignRe = regexp.MustCompile(`^(\w+=\S+\s+)+`) // pctExecRe matches "pct exec [--] " and captures . The // id is a decimal digit string (Proxmox CT ids). The "--" separator is // optional but recommended — without it, the rest of the line is the // command passed to exec. Case-insensitive. var pctExecRe = regexp.MustCompile(`(?i)^pct\s+exec\s+\d+\s+(?:--\s+)?(.+)$`) // qmGuestExecRe matches "qm guest exec [--] " similarly. var qmGuestExecRe = regexp.MustCompile(`(?i)^qm\s+guest\s+exec\s+\d+\s+(?:--\s+)?(.+)$`) // shellDashCRe matches "bash -c 'cmd'", "sh -c \"cmd\"" etc., capturing // the quoted inner command. Handles single-quoted, double-quoted, and bare // (unquoted) forms. var shellDashCRe = regexp.MustCompile(`(?i)^(?:ba)?sh\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`) // curlLeadRe matches a curl command (the verb alone, at the segment start). var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`) // curlMutateRe matches curl flags that indicate mutation (POST/PUT/DELETE // method override, data payloads, form uploads, file uploads, file output). // When any of these appears, the curl command is no longer read-only. var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`) // redirectOutRe matches shell output redirection to a file (> or >> followed // by a path), but excludes the file-descriptor merge form `>&` (e.g. // `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has // no lookahead, so we encode the exclusion by requiring the post-`>` char to // be neither `&` nor whitespace. var redirectOutRe = regexp.MustCompile(`(^|[^-])>>?\s*[^&\s]`) // 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 } // Unwrap known wrappers (pct exec --, qm guest exec --, // bash -c '…', sh -c '…', sudo, env assignments) so the classifier // scores the *actual* command, not the wrapper. Without this, every // `pct exec 132 systemctl status rclone-backup.timer` escalates to // config_mutation even though the inner command is read-only inspection. // See plans/2026-07-20-session-review-ten-sessions.md P0.1 — three // sessions bounced off the classifier because read-only `pct exec` and // `curl` were gated as config_mutation. inner := unwrapCommand(cmd) for _, p := range destructivePatterns { // Match on both the raw and unwrapped forms so that // `pct exec 121 -- rm -rf /` is still destructive even if the // unwrapping somehow hid it. if p.MatchString(inner) || 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(inner) { if allSegmentsReadOnly(inner) { return RiskReadOnly } } // Not obviously destructive, not a recognized read-only inspection — // default to the gated tier rather than guessing it's safe. return RiskConfigMutation } // unwrapCommand peels known command wrappers to expose the inner command // for classification. It repeatedly strips: // - leading sudo // - leading FOO=bar env-var assignments // - `pct exec [--] ` → // - `qm guest exec [--] ` → // - `bash -c 'cmd'` / `sh -c "cmd"` → // // When no wrapper is detected, the input is returned unchanged. The peel // is iterative so "sudo pct exec 121 -- bash -c 'echo hi'" reduces to // "echo hi" after a few passes. Compound commands (containing ;, &&, ||, // |) are returned unchanged — they need per-segment classification, which // the caller handles. func unwrapCommand(cmd string) string { probe := strings.TrimSpace(cmd) // A compound command cannot be unwrapped as a whole — the inner // command of "pct exec 121 -- foo; rm -rf /" depends on which side of // the ";" you're on. The caller splits compounds before classifying // each segment, and each segment is unwrapped independently. Bail out // here so we don't unwrap "pct exec 121 -- foo" and lose the rest. if compoundOpPattern.MatchString(probe) { return probe } for i := 0; i < 8; i++ { // bounded unwrap depth next := peelOneWrapper(probe) if next == probe { return probe } probe = strings.TrimSpace(next) } return probe } // peelOneWrapper applies one peel step. Returns the input unchanged if no // wrapper matched. func peelOneWrapper(probe string) string { // sudo prefix if stripped := strings.TrimPrefix(probe, "sudo "); stripped != probe { return strings.TrimSpace(stripped) } // Env assignments: FOO=bar BAZ=qux if envAssignRe.MatchString(probe) { return envAssignRe.ReplaceAllString(probe, "") } // pct exec [--] if m := pctExecRe.FindStringSubmatch(probe); m != nil { return m[1] } // qm guest exec [--] if m := qmGuestExecRe.FindStringSubmatch(probe); m != nil { return m[1] } // bash -c 'cmd' / sh -c "cmd" / sh -c cmd if m := shellDashCRe.FindStringSubmatch(probe); m != nil { // m[1] is the double-quoted form, m[2] is single-quoted, m[3] is bare. for _, g := range m[1:] { if g != "" { return g } } } return probe } // 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 } // Unwrap wrappers per-segment too — "pct exec 121 -- systemctl // status caddy; pct exec 122 -- journalctl -u caddy" should reduce // to two read-only segments after unwrapping each. seg = unwrapCommand(seg) // Strip a leading sudo/env assignment so "sudo cat /x" still matches. probe := seg probe = strings.TrimPrefix(probe, "sudo ") probe = envAssignRe.ReplaceAllString(probe, "") probe = strings.TrimSpace(probe) // curl is handled by a dedicated check because GET (the default) is // read-only but POST/data/upload flags are not. The general // readOnlyLeadPattern can't distinguish these. if curlLeadRe.MatchString(probe) { if !curlIsReadOnly(probe) { return false } continue } // Any output redirection makes a verb non-read-only even if the // verb itself is (e.g. "curl url > /etc/passwd"). if redirectOutRe.MatchString(probe) { return false } if !readOnlyLeadPattern.MatchString(probe) { return false } } return len(segments) > 0 } // curlIsReadOnly returns true if a curl command performs a GET (or HEAD) // without data/upload/output flags. POST/PUT/DELETE method overrides, -d/--data // payloads, -F/--form uploads, -T/--upload-file transfers, and -o/--output // file writes all disqualify the read-only path. func curlIsReadOnly(curlCmd string) bool { if !curlLeadRe.MatchString(curlCmd) { return false } if curlMutateRe.MatchString(curlCmd) { return false } if redirectOutRe.MatchString(curlCmd) { return false } return true }