Files
oikos/internal/policy/command.go
dtoro 20adb89650
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
v0.18.0: MCP entity-graph CRUD, lifecycle validation, curl -o /dev/null fix
- create_entity, set_entity_state, end_relationship MCP tools
- update_entity_attributes now triggers check derivation via EnsureEntityChecks
- shared db.EnsureEntityChecks + db.ValidateTransition hooks (HTTP + MCP parity)
- curl -o /dev/null now classified read_only (was config_mutation)
- db.ErrTransitionInvalid sentinel for HTTP error-type accuracy
- SOUL.md: capability escalation, self-grounding, exploration budget rules
- Runbook: oikos check lifecycle for agent self-knowledge
2026-08-04 08:52:08 +02:00

332 lines
14 KiB
Go

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 <id> [--] <inner>" and captures <inner>. 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 <id> [--] <inner>" 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`)
// curlDevNullOutRe matches curl output redirected to /dev/null in any of curl's
// argument forms (space, =, or attached). /dev/null is a no-op sink, so a GET
// that discards its body — the canonical reachability idiom
// `curl -o /dev/null -w '%{http_code}' URL` — is read-only. Output to any real
// path (-o /tmp/x) stays a potential mutation. Stripped before curlMutateRe so
// the remaining flags (-X, -d, ...) still classify correctly: a
// `curl -o /dev/null -X POST` stays config_mutation.
var curlDevNullOutRe = regexp.MustCompile(`(?i)(^|\s)-o\s*/dev/null(\s|$)|(^|\s)--output[=\s]\s*/dev/null(\s|$)`)
// redirectOutRe matches shell output redirection to a file (> or >> followed
// by a path), but excludes the file-descriptor merge form `>&<digit>` (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 <id> --, qm guest exec <id> --,
// 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 <id> [--] <inner>` → <inner>
// - `qm guest exec <id> [--] <inner>` → <inner>
// - `bash -c 'cmd'` / `sh -c "cmd"` → <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 <cmd>
if envAssignRe.MatchString(probe) {
return envAssignRe.ReplaceAllString(probe, "")
}
// pct exec <id> [--] <inner>
if m := pctExecRe.FindStringSubmatch(probe); m != nil {
return m[1]
}
// qm guest exec <id> [--] <inner>
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
}
// -o /dev/null is a no-op sink: strip it before flag detection so the
// canonical GET-and-discard reachability probe stays read-only.
// A `curl -o /dev/null -X POST` still fails curlMutateRe after stripping.
curlCmd = curlDevNullOutRe.ReplaceAllString(curlCmd, " ")
if curlMutateRe.MatchString(curlCmd) {
return false
}
if redirectOutRe.MatchString(curlCmd) {
return false
}
return true
}