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.
271 lines
10 KiB
Go
271 lines
10 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|