Files
oikos/internal/core/app/policy_test.go
dtoro 7c9f4ec79f 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.
2026-08-16 09:48:26 +02:00

271 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}