// Package app — PolicyService: the classify→gate decision pipeline for // agent command execution. Extracted verbatim-in-order from the MCP // classifyAndGate path (hexagonal Phase 4); every refusal message and // gate ordering is preserved. The pure half (classification rules, // syntax/target validation, window evaluation) lives here; the facts // that need the store (plan state, windows, dedup, QGA attribute) come // through ports.GovernanceStore. package app import ( "context" "encoding/json" "fmt" "regexp" "strings" "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/core/ports" "github.com/dtoro/oikos/internal/policy" ) // PolicyService evaluates whether a submitted command auto-runs, queues // for operator approval, or is refused outright. type PolicyService struct { store ports.GovernanceStore // HostHint, when wired at composition time, resolves a better // Proxmox-host suggestion for host-only command refusals. Optional; // the default hint is used when nil or when it returns "". HostHint func(ctx context.Context, targetSlug string) string } // NewPolicyService wires the service over the governance read model. func NewPolicyService(store ports.GovernanceStore) *PolicyService { return &PolicyService{store: store} } // PolicySubmitInput is one gating evaluation request. type PolicySubmitInput struct { AgentID domain.UUID TargetID domain.UUID TargetSlug string Command string Purpose string DeclaredRisk string SessionID string } // Decision actions. const ( DecisionRefuse = "refuse" // return Message to the agent; nothing recorded DecisionAuto = "auto_run" // execute now (via window named in Route) DecisionQueue = "queue_approval" // persist + ask the operator ) // PolicyDecision is the outcome of the gate pipeline. type PolicyDecision struct { Action string // DecisionRefuse | DecisionAuto | DecisionQueue RiskClass string Route string // "auto-act" | "escalate" — the classifications-table route Message string // for DecisionRefuse: the agent-facing refusal; else "" // AutoViaWindow names why an auto-run is allowed for non-read-only // classes: "", "assent", or "destructive". read_only/reversible_low // auto-run unattended by policy, not by window. AutoViaWindow string } // Classify computes the risk class: the text classifier's verdict, kept // at or above the agent's declaration (a declaration can only escalate), // then transport-aware escalation — a read classified command on an LXC // target that touches config paths (/opt/, /etc/, /var/lib/) escalates // to config_mutation, because SSH-ing into a container to read config is // riskier than the same read via pct exec from the host. Log-file reads // (tail/head/cat/less/journalctl on *.log or */logs/*) are exempt. func (s *PolicyService) Classify(command, declaredRisk, targetSlug string) string { riskClass := policy.ClassifyCommand(command, declaredRisk) if riskClass == policy.RiskReadOnly && strings.HasPrefix(targetSlug, "lxc:") { if !IsLogInspectionRead(command) { if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") { riskClass = policy.RiskConfigMutation } } } return riskClass } // Decide runs the full gate pipeline in order: classify, plan-first, // syntax, host-only command, host/lxc-only command, VM guest-agent // pre-flight, duplicate-pending, approval-flood, then the route // evaluation (risk class × windows). Returns the decision; the caller // records and dispatches per Action. func (s *PolicyService) Decide(ctx context.Context, in PolicySubmitInput) PolicyDecision { riskClass := s.Classify(in.Command, in.DeclaredRisk, in.TargetSlug) // P1 plan-first gate: every task must propose a plan before any `run`, // read-only or not. Without this gate the "MANDATORY TASK FLOW" is // unenforceable prose — weaker models skip propose_plan and leave the // operator with 23 individual approvals and no plan. sessionID == "" // means a direct MCP call with no nomos session — no-op there. if in.SessionID != "" && !s.store.SessionHasPlan(ctx, in.SessionID) { return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: "No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists."} } // Command syntax validation: catch LLM-generated bash bugs before they // hit the shell. if syntaxErr := ValidateCommandSyntax(in.Command); syntaxErr != "" { return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: syntaxErr} } // Target validation: host-only commands (qm, pct, pvesh, iptables) // must not be dispatched against lxc:/vm: targets. if cmdPrefix, hostOnly := HostOnlyCommand(in.Command); hostOnly && !strings.HasPrefix(in.TargetSlug, "host:") { hostSuggestion := s.proxmoxHostHint(ctx, in.TargetSlug) return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf("Cannot run %q on %s — %s is a Proxmox host command. Use target %s instead.", cmdPrefix, in.TargetSlug, cmdPrefix, hostSuggestion)} } // systemctl and docker work on hosts and LXCs, but not VMs. if cmdPrefix, hostLxc := HostLxcCommand(in.Command); hostLxc { if !strings.HasPrefix(in.TargetSlug, "host:") && !strings.HasPrefix(in.TargetSlug, "lxc:") { return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf("Cannot run %q on %s — %s only works on host:* or lxc:* targets.", cmdPrefix, in.TargetSlug, cmdPrefix)} } } // VM transport pre-flight: qm guest exec requires the QEMU guest agent // to be running inside the VM; if it's not, the execution would queue // and never execute. if strings.HasPrefix(in.TargetSlug, "vm:") { if attrs, err := s.store.EntityAttributes(ctx, in.TargetID); err == nil { if qga, ok := attrs["qemu_guest_agent"]; ok { qgaStr, _ := qga.(string) if qgaStr == "not_running" || qgaStr == "" { return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf( "run on %s blocked: QEMU guest agent is not running (%s). qm guest exec cannot reach this VM. Start the agent inside the guest first (e.g. via SSH/systemctl start qemu-guest-agent), then re-run. If the agent is running but the entity attribute is stale, update it with update_entity_attributes(slug=%s, attributes={\"qemu_guest_agent\":\"running\"}).", in.TargetSlug, qgaStr, in.TargetSlug)} } } } } // Dedup: an identical pending command blocks a re-request — stops a // tool-calling loop from queuing the same approval repeatedly. runParams := RunActionParams(in.Command, in.Purpose) if existingID, dup := s.store.PendingDuplicate(ctx, in.TargetID, runParams); dup { return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", in.TargetSlug, existingID)} } // Approval-flood gate: for config_mutation with no assent window and // already one pending approval in the session, refuse — the operator // should see ONE approval (the plan), not N. if riskClass == policy.RiskConfigMutation && in.SessionID != "" && !s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) { if s.store.PendingApprovalCount(ctx, in.SessionID) > 0 { return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: "An approval is already pending for this plan. Present the plan and its steps to the operator, then STOP and wait for their approval (\"approved\", \"yes\", \"go ahead\"). Do not call run again until the operator responds — after approval, all config_mutation commands will auto-run."} } } return s.route(ctx, in, riskClass) } // route evaluates risk class against the auto-run windows. func (s *PolicyService) route(ctx context.Context, in PolicySubmitInput, riskClass string) PolicyDecision { // read_only and reversible_low run unattended, as seeds/policy.yaml // declares. reversible_low can only arise when the agent declares it // on a command the classifier already scored read_only (the classifier // keeps the higher class), so auto-running it is no more permissive // than the read_only branch — candor is never punished. if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow { return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act"} } // Assent window: the operator approved the plan in this session; // config_mutation within the window auto-runs. if riskClass == policy.RiskConfigMutation && s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) { return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act", AutoViaWindow: "assent"} } // Destructive window: a narrow, target-scoped grant opened only by an // explicit typed confirmation on this same target. if riskClass == policy.RiskDestructive && s.store.DestructiveWindowActive(ctx, in.AgentID, in.TargetSlug, in.SessionID) { return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act", AutoViaWindow: "destructive"} } return PolicyDecision{Action: DecisionQueue, RiskClass: riskClass, Route: "escalate"} } // proxmoxHostHint suggests a Proxmox host target for host-only command // refusals. Lazily resolved only when such a refusal fires. func (s *PolicyService) proxmoxHostHint(ctx context.Context, targetSlug string) string { if s.HostHint != nil { if hint := s.HostHint(ctx, targetSlug); hint != "" { return hint } } return "host:hubris or host:strong" } // RunActionParams renders the executions.action column value for a run // submission: "run:{json command+purpose}". func RunActionParams(command, purpose string) string { b, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose}) return "run:" + string(b) } // ─── Pure classification helpers (moved from internal/mcp) ──────────── // hostOnlyCommands maps command prefixes only valid on Proxmox host // targets. Running these against an lxc: or vm: target always fails with // "command not found" and wastes a turn. var hostOnlyCommands = map[string]bool{ "qm": true, "pct": true, "pvesh": true, "iptables": true, } // hostLxcCommands maps command prefixes valid on host:* and lxc:* but // not vm:*. var hostLxcCommands = map[string]bool{ "systemctl": true, "docker": true, } // HostOnlyCommand checks whether the leading word of cmd is a host-only // command. Returns the command word and true if it can only run on a // host: target. func HostOnlyCommand(cmd string) (string, bool) { first := leadingCommandWord(cmd) return first, hostOnlyCommands[first] } // HostLxcCommand checks whether the leading word of cmd is a command // valid on host:* and lxc:* targets but not vm:*. func HostLxcCommand(cmd string) (string, bool) { first := leadingCommandWord(cmd) return first, hostLxcCommands[first] } // leadingCommandWord extracts the first word of the actual command, // seeing through `bash -c '...'` wrappers and stripping paths // (/usr/sbin/qm → qm). func leadingCommandWord(cmd string) string { trimmed := strings.TrimSpace(cmd) parts := strings.Fields(trimmed) if len(parts) == 0 { return "" } first := parts[0] if (first == "bash" || first == "sh") && len(parts) >= 3 && parts[1] == "-c" { actual := strings.Trim(strings.Join(parts[2:], " "), "'\"") if inner := strings.Fields(actual); len(inner) > 0 { first = inner[0] } } if idx := strings.LastIndexByte(first, '/'); idx >= 0 { first = first[idx+1:] } return first } // IsLogInspectionRead returns true for a safe read-only operation on a // log file — tail, head, cat, less, or journalctl with a .log or /logs/ // path. Exempt from the LXC transport escalation: log inspection is the // most common debugging action. func IsLogInspectionRead(cmd string) bool { trimmed := strings.TrimSpace(cmd) for _, prefix := range []string{"tail ", "head ", "cat ", "less ", "journalctl "} { if strings.HasPrefix(trimmed, prefix) { if strings.Contains(trimmed, ".log") || strings.Contains(trimmed, "/logs/") { return true } } } return false } // ValidateCommandSyntax checks for common LLM-generated bash errors that // always fail at the shell. Returns an error message or "". func ValidateCommandSyntax(cmd string) string { if strings.Contains(cmd, "\\n") { return fmt.Sprintf("Command contains literal '\\n' — use ';' or '&&' between commands, not a literal backslash-n. Command: %q", cmd) } if andBackslashRe.MatchString(cmd) { return fmt.Sprintf("Command contains '&&' followed by a literal backslash-newline — remove the backslash or use ';' instead. Command: %q", cmd) } trimmed := strings.TrimSpace(cmd) if strings.HasSuffix(trimmed, "\\") { return fmt.Sprintf("Command ends with a backslash but has nothing after it to continue. Remove the trailing '\\'. Command: %q", cmd) } if flagSpaceRe.MatchString(cmd) { return fmt.Sprintf("Command has a space between a flag and its value (e.g. 'head - n' instead of 'head -n'). Remove the space. Command: %q", cmd) } return "" } var ( andBackslashRe = regexp.MustCompile(`&&\s*\\\s*\n`) // flagSpaceRe catches a space BETWEEN the dash and the flag's value // ("head - n 5"), the LLM typo the gate exists for. The pre-extraction // pattern `(-\w)\s+\w` matched the VALID spelling ("tail -n 3 file") // and missed the typo — every properly-written `tail -n` was refused // with a bogus message while "head - n" sailed through. Surfaced by // the Phase 4 gating-matrix tests; fixed here. flagSpaceRe = regexp.MustCompile(`\b(head|tail|grep|sed|awk|sort|uniq|wc)\s+-\s+\w`) sleepRe = regexp.MustCompile(`\bsleep\s+\d`) pollRe = regexp.MustCompile(`\bwhile\b.*\bsleep\b`) waitRe = regexp.MustCompile(`\bwait\s+\d|[&;]\s*wait\b`) ) // IsLongRunningCommand detects shell commands containing sleep, wait, or // poll loops that indicate the command will exceed the MCP client // timeout (120s). These dispatch async (server-side continuation). func IsLongRunningCommand(cmd string) bool { cmd = strings.TrimSpace(cmd) if sleepRe.MatchString(cmd) { return true } if pollRe.MatchString(cmd) { return true } if waitRe.MatchString(cmd) { return true } return false }