feat: Phase 4 governance/execution slice — PolicyService + ExecutionService

classifyAndGate's decision pipeline moves to core: PolicyService runs the
full gate order (classify + transport escalation, plan-first, syntax,
host-only/host-lxc, VM QGA preflight, dedup, approval-flood, window
routing) over ports.GovernanceStore; ExecutionService records and
dispatches (auto-run via ssh.CommandExecutor + TargetResolver, queue via
ExecutionRecorder) with one converged path for run/docker_exec. Gating
matrix test added (risk x window x declared risk -> outcome); pair
coverage 95.6%.

Bug fix surfaced by the matrix: the flag-space syntax regex was inverted
— it refused valid 'tail -n 3' and missed the actual 'head - n' typo.
Fixed to match dash-space-value only.

Remaining Phase 4 items tracked in the plan: ApprovalService.Decide
convergence, execlog fold, execworker poller. VERSION 0.35.0.
This commit is contained in:
2026-08-16 09:48:26 +02:00
parent 60c0432d8b
commit 7c9f4ec79f
19 changed files with 1748 additions and 725 deletions

View File

@@ -0,0 +1,270 @@
package app
import (
"context"
"strings"
"testing"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports/portstest"
)
const (
agentID = domain.UUID("11111111-1111-1111-1111-111111111111")
targetID = domain.UUID("22222222-2222-2222-2222-222222222222")
vmID = domain.UUID("33333333-3333-3333-3333-333333333333")
sessionOK = "s1"
)
func newPolicySvc(t *testing.T) (*PolicyService, *portstest.GovernanceStore) {
t.Helper()
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
return NewPolicyService(store), store
}
func submit(targetSlug, command, declaredRisk string) PolicySubmitInput {
return PolicySubmitInput{
AgentID: agentID,
TargetID: targetID,
TargetSlug: targetSlug,
Command: command,
Purpose: "test",
DeclaredRisk: declaredRisk,
SessionID: sessionOK,
}
}
// TestGatingMatrix is the Phase 9 gate: risk class × autonomy window ×
// declared risk → outcome (auto-run / queue), asserted as a table. Line
// coverage alone cannot prove the classifier.
func TestGatingMatrix(t *testing.T) {
// Command pairs: one that computes to each class, plus the declared-
// risk escalations. computeClass trusts policy.ClassifyCommand — the
// same function production uses.
classes := []struct {
name string
command string
decl string
}{
{"read_only", "df -h", ""},
{"read_only declared destructive", "df -h", "destructive"}, // declaration can only escalate
{"read_only declared reversible", "df -h", "reversible_low"}, // stays reversible (higher of the two)
{"reversible_low via declaration", "systemctl restart caddy", "reversible_low"},
{"config_mutation", "systemctl restart caddy", ""},
{"destructive", "rm -rf /tmp/x", ""},
}
windows := []struct {
name string
setup func(*portstest.GovernanceStore)
assent bool
destructive bool
}{
{"no window", func(*portstest.GovernanceStore) {}, false, false},
{"assent window", func(s *portstest.GovernanceStore) { s.Assent[string(agentID)+"/"+sessionOK] = true }, true, false},
{"destructive window", func(s *portstest.GovernanceStore) { s.Destructive[string(agentID)+"/lxc:x/"+sessionOK] = true }, false, true},
}
// expected outcome matrix: class → window → want. "systemctl restart"
// computes config_mutation; the reversible_low declaration cannot lower
// it — reversible_low only survives on read_only-computed commands.
want := map[string]map[string]string{
"read_only": {"no window": "auto", "assent window": "auto", "destructive window": "auto"},
"read_only declared destructive": {"no window": "queue", "assent window": "queue", "destructive window": "auto"},
"read_only declared reversible": {"no window": "auto", "assent window": "auto", "destructive window": "auto"},
"reversible_low via declaration": {"no window": "queue", "assent window": "auto", "destructive window": "queue"},
"config_mutation": {"no window": "queue", "assent window": "auto", "destructive window": "queue"},
"destructive": {"no window": "queue", "assent window": "queue", "destructive window": "auto"},
}
for _, cls := range classes {
for _, win := range windows {
t.Run(cls.name+"/"+win.name, func(t *testing.T) {
svc, store := newPolicySvc(t)
win.setup(store)
d := svc.Decide(context.Background(), submit("lxc:x", cls.command, cls.decl))
got := map[string]string{DecisionAuto: "auto", DecisionQueue: "queue", DecisionRefuse: "refuse"}[d.Action]
if wantAction, ok := want[cls.name][win.name]; ok && got != wantAction {
t.Errorf("class %q window %q: action = %q (route %q, risk %s), want %q — decision %+v",
cls.name, win.name, got, d.Route, d.RiskClass, wantAction, d)
}
})
}
}
}
// TestDeclarationCannotLowerRisk: declaring read_only on a destructive
// command keeps destructive — the agent cannot talk a command down.
func TestDeclarationCannotLowerRisk(t *testing.T) {
svc, _ := newPolicySvc(t)
d := svc.Decide(context.Background(), submit("lxc:x", "rm -rf /data", "read_only"))
if d.RiskClass != "destructive" {
t.Errorf("risk class = %q, want destructive (declaration must not lower)", d.RiskClass)
}
if d.Action != DecisionQueue {
t.Errorf("action = %q, want queue", d.Action)
}
}
// TestTransportEscalation: read-classified commands on LXC targets that
// touch config paths escalate to config_mutation; log reads are exempt.
func TestTransportEscalation(t *testing.T) {
svc, _ := newPolicySvc(t)
cases := []struct {
slug string
command string
want string
}{
{"lxc:dns", "cat /etc/hostname", "config_mutation"}, // caught live pre-extraction
{"lxc:seanime", "tail -3 /opt/seanime/data/logs/seanime.log", "read_only"}, // log inspection exemption
{"lxc:x", "ls /opt/app", "config_mutation"},
{"lxc:x", "cat /var/lib/foo", "config_mutation"},
{"host:hubris", "cat /etc/hostname", "read_only"}, // host reads don't escalate
{"lxc:x", "df -h", "read_only"}, // no config path
}
for _, c := range cases {
if got := svc.Classify(c.command, "", c.slug); got != c.want {
t.Errorf("Classify(%q on %s) = %q, want %q", c.command, c.slug, got, c.want)
}
}
}
func TestPlanFirstGate(t *testing.T) {
svc, store := newPolicySvc(t)
delete(store.PlanSessions, sessionOK)
d := svc.Decide(context.Background(), submit("lxc:x", "df -h", ""))
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "No plan for this session") {
t.Errorf("decision = %+v, want plan-first refusal", d)
}
// No session → no gate.
d = svc.Decide(context.Background(), PolicySubmitInput{AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x", Command: "df -h"})
if d.Action != DecisionAuto {
t.Errorf("no-session decision = %+v, want auto", d)
}
}
func TestSyntaxValidationGate(t *testing.T) {
svc, _ := newPolicySvc(t)
for _, bad := range []string{
"echo hi && \\n curl x", // literal backslash-n between commands
"cat /etc/passwd\\n", // literal backslash-n in path
"head - n 5 /x", // space between flag and value (the LLM typo)
"grep - i pattern /f", // same, grep
"tail \\", // trailing backslash, no continuation
} {
d := svc.Decide(context.Background(), submit("lxc:x", bad, ""))
if d.Action != DecisionRefuse {
t.Errorf("command %q: action = %q, want refuse", bad, d.Action)
}
}
// The correctly-spelled forms must NOT be refused (the pre-extraction
// regex inverted this — "tail -n 3" was refused, "tail - n" was not).
for _, ok := range []string{"tail -n 3 /var/log/syslog", "head -n 5 /x", "df -h"} {
d := svc.Decide(context.Background(), submit("lxc:x", ok, ""))
if d.Action == DecisionRefuse {
t.Errorf("valid command %q refused: %s", ok, d.Message)
}
}
}
func TestHostOnlyCommandGate(t *testing.T) {
svc, _ := newPolicySvc(t)
d := svc.Decide(context.Background(), submit("lxc:dns", "qm stop 100", ""))
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "Proxmox host command") {
t.Errorf("decision = %+v, want host-only refusal", d)
}
// Same command on a host target passes the gate (queues or runs).
d = svc.Decide(context.Background(), submit("host:hubris", "pct list", ""))
if d.Action == DecisionRefuse {
t.Errorf("host target refused: %+v", d)
}
}
func TestHostLxcCommandGate(t *testing.T) {
svc, _ := newPolicySvc(t)
d := svc.Decide(context.Background(), submit("vm:zimaos", "systemctl status foo", ""))
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "host:* or lxc:*") {
t.Errorf("decision = %+v, want host/lxc-only refusal", d)
}
}
func TestVMGuestAgentGate(t *testing.T) {
svc, store := newPolicySvc(t)
store.Attrs[vmID] = map[string]any{"qemu_guest_agent": "not_running"}
in := submit("vm:zimaos", "df -h", "")
in.TargetID = vmID
d := svc.Decide(context.Background(), in)
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "QEMU guest agent is not running") {
t.Errorf("decision = %+v, want QGA refusal", d)
}
// Running agent → passes.
store.Attrs[vmID] = map[string]any{"qemu_guest_agent": "running"}
if d := svc.Decide(context.Background(), in); d.Action != DecisionAuto {
t.Errorf("decision = %+v, want auto", d)
}
}
func TestDuplicatePendingGate(t *testing.T) {
svc, store := newPolicySvc(t)
store.Duplicates[string(targetID)+"\x00"+RunActionParams("systemctl restart caddy", "test")] = "exec-1"
d := svc.Decide(context.Background(), submit("lxc:x", "systemctl restart caddy", ""))
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "already queued for approval") {
t.Errorf("decision = %+v, want dedup refusal", d)
}
}
func TestApprovalFloodGate(t *testing.T) {
svc, store := newPolicySvc(t)
store.PendingApprovals[sessionOK] = 1
d := svc.Decide(context.Background(), submit("lxc:x", "systemctl enable caddy", ""))
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "An approval is already pending") {
t.Errorf("decision = %+v, want flood-gate refusal", d)
}
// read_only is never subject to the flood gate.
if d := svc.Decide(context.Background(), submit("lxc:x", "df -h", "")); d.Action != DecisionAuto {
t.Errorf("read_only flood decision = %+v, want auto", d)
}
}
func TestClassifyHelpers(t *testing.T) {
if !IsLongRunningCommand("sleep 30 && echo done") || !IsLongRunningCommand("while true; do x; sleep 1; done") {
t.Error("long-running detection failed")
}
if IsLongRunningCommand("df -h") {
t.Error("df -h is not long-running")
}
if w, ok := HostOnlyCommand("bash -c '/usr/sbin/qm list'"); !ok || w != "qm" {
t.Errorf("HostOnlyCommand through wrapper = %q,%v want qm,true", w, ok)
}
if _, ok := HostLxcCommand("docker ps"); !ok {
t.Error("docker should be host/lxc-only")
}
if _, ok := HostLxcCommand("df -h"); ok {
t.Error("df should not be host/lxc-only")
}
if got := RunActionParams("a", "b"); !strings.HasPrefix(got, "run:{") {
t.Errorf("RunActionParams = %q", got)
}
}
func TestIsLongRunningCommandMatrix(t *testing.T) {
cases := []struct {
cmd string
want bool
}{
{"sleep 30", true},
{"sleep 5m && df -h", true},
{"while true; do date; sleep 1; done", true},
{"cmd1 & wait", true},
{"wait 123", true},
{"df -h", false},
{"systemctl status caddy", false},
{"echo asleep", false}, // substring but not the sleep command
}
for _, c := range cases {
if got := IsLongRunningCommand(c.cmd); got != c.want {
t.Errorf("IsLongRunningCommand(%q) = %v, want %v", c.cmd, got, c.want)
}
}
}