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:
270
internal/core/app/policy_test.go
Normal file
270
internal/core/app/policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user