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) } } }