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,210 @@
package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
// ExecutionService is the run-submission use-case: gate via PolicyService,
// record the submission, then either execute immediately over the
// CommandExecutor port or queue an operator approval. Both entry paths
// (MCP `run`/`docker_exec` tools, and any future REST surface) converge
// here — one policy, one audit trail (plan §4.A).
type ExecutionService struct {
policy *PolicyService
exec ports.CommandExecutor
resolver ports.TargetResolver
recorder ports.ExecutionRecorder
}
// NewExecutionService wires the service.
func NewExecutionService(
policy *PolicyService,
exec ports.CommandExecutor,
resolver ports.TargetResolver,
recorder ports.ExecutionRecorder,
) *ExecutionService {
return &ExecutionService{policy: policy, exec: exec, resolver: resolver, recorder: recorder}
}
// ExecutionSubmitCmd is one `run` submission.
type ExecutionSubmitCmd struct {
AgentID domain.UUID
TargetID domain.UUID
TargetSlug string
Command string
Purpose string
DeclaredRisk string
SessionID string
// Async, when true and the decision is auto-run, dispatches the
// execution in a background goroutine and returns immediately with
// AsyncStarted — for long-running commands that would exceed the
// caller's round-trip timeout. Set it via PolicyService's
// IsLongRunningCommand at the caller, or directly.
Async bool
// SinkFactory builds the output stream sink for the execution once
// its id and correlation id are known (the adapter wires its log
// streaming here; nil means no streaming).
SinkFactory func(ctx context.Context, executionID domain.UUID, correlationID string) (sink func(stream string, chunk []byte), flush func())
}
// ExecutionSubmitResult is the submission outcome for the calling
// adapter to render.
type ExecutionSubmitResult struct {
Decision PolicyDecision
// ExecutionID is set for every non-refused submission (auto and
// queue paths) — the ledger id to poll with get_execution_status.
ExecutionID domain.UUID
// Output/Error carry the command result when it ran synchronously.
Output string
Err error
// AsyncStarted reports the execution was dispatched in the
// background.
AsyncStarted bool
}
// Submit gates, records, and dispatches one command. Refusals return the
// agent-facing message without recording anything; auto-run paths record
// the submission then execute; queue paths record and create the
// approval ask.
func (s *ExecutionService) Submit(ctx context.Context, cmd ExecutionSubmitCmd) ExecutionSubmitResult {
decision := s.policy.Decide(ctx, PolicySubmitInput{
AgentID: cmd.AgentID,
TargetID: cmd.TargetID,
TargetSlug: cmd.TargetSlug,
Command: cmd.Command,
Purpose: cmd.Purpose,
DeclaredRisk: cmd.DeclaredRisk,
SessionID: cmd.SessionID,
})
if decision.Action == DecisionRefuse {
return ExecutionSubmitResult{Decision: decision}
}
execID, err := uuid.NewV7()
if err != nil {
return ExecutionSubmitResult{Decision: decision, Err: err}
}
// Correlate the execution to the chat session that asked for it —
// execution events carry correlation_id, so the UI can match them to
// the session on screen. Fresh random id when there is no session.
correlationID := cmd.SessionID
if correlationID == "" || correlationID == "ephemeral" {
correlationID = uuid.New().String()
}
reason, _ := json.Marshal(map[string]string{
"command": cmd.Command, "purpose": cmd.Purpose,
"target": cmd.TargetSlug, "declared_risk": cmd.DeclaredRisk,
})
if err := s.recorder.CreateRun(ctx, ports.CreateRunInput{
ExecutionID: domain.UUID(execID.String()),
TargetID: cmd.TargetID,
TargetSlug: cmd.TargetSlug,
AgentID: cmd.AgentID,
SessionID: cmd.SessionID,
Command: cmd.Command,
Purpose: cmd.Purpose,
Action: RunActionParams(cmd.Command, cmd.Purpose),
RiskClass: decision.RiskClass,
Route: decision.Route,
ClassReasoning: reason,
CorrelationID: correlationID,
}); err != nil {
return ExecutionSubmitResult{Decision: decision, Err: fmt.Errorf("record execution: %w", err)}
}
execUUID := domain.UUID(execID.String())
// Build the streaming sink now that the execution id is known.
var sink func(string, []byte)
var flush func()
if cmd.SinkFactory != nil {
sink, flush = cmd.SinkFactory(ctx, execUUID, correlationID)
}
switch decision.Action {
case DecisionQueue:
params, _ := json.Marshal(map[string]string{"command": cmd.Command, "purpose": cmd.Purpose})
if err := s.recorder.QueueApproval(ctx, ports.QueueApprovalInput{
ExecutionID: execUUID,
TargetID: cmd.TargetID,
TargetSlug: cmd.TargetSlug,
Action: "run",
Params: string(params),
RiskClass: decision.RiskClass,
SessionID: cmd.SessionID,
}); err != nil {
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, Err: fmt.Errorf("queue approval: %w", err)}
}
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID}
case DecisionAuto:
if cmd.Async {
safego.Go("execution:async-run", func() {
// Result surfaces via the ledger (get_execution_status);
// the handler already returned "started".
_, _ = s.dispatch(context.Background(), execUUID, cmd.TargetSlug, cmd.Command, sink, flush)
})
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, AsyncStarted: true}
}
out, xerr := s.dispatch(ctx, execUUID, cmd.TargetSlug, cmd.Command, sink, flush)
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, Output: out, Err: xerr}
}
return ExecutionSubmitResult{Decision: decision, ExecutionID: execUUID, Err: fmt.Errorf("unknown decision action %q", decision.Action)}
}
// dispatch resolves the target, marks the execution running, executes
// with streaming, and finalizes with full timing. Shared by the sync and
// async auto-run paths.
func (s *ExecutionService) dispatch(ctx context.Context, execID domain.UUID, targetSlug, command string, sink func(string, []byte), flush func()) (string, error) {
startedAt := time.Now()
if err := s.recorder.MarkRunning(ctx, execID); err != nil {
slog.Error("execution: mark running", "error", err, "execution_id", execID)
}
target, err := s.resolver.ResolveExecTarget(ctx, targetSlug)
if err != nil {
s.finalize(ctx, execID, "failed", jsonErrBytes("%s", err.Error()), startedAt)
return "", err
}
res := s.exec.Run(ctx, target, command, ports.ExecOpts{Sink: sink})
if flush != nil {
flush()
}
if res.Err != nil {
s.finalize(ctx, execID, "failed", jsonErrBytes("%s: %s", res.Err.Error(), res.Output), startedAt)
return res.Output, res.Err
}
s.finalize(ctx, execID, "completed", jsonOutBytes(res.Output), startedAt)
return res.Output, nil
}
func (s *ExecutionService) finalize(ctx context.Context, execID domain.UUID, status string, result []byte, startedAt time.Time) {
if err := s.recorder.Finalize(ctx, ports.FinalizeExecutionInput{
ExecutionID: execID, Status: status, Result: result, StartedAt: startedAt,
}); err != nil {
slog.Error("execution: finalize", "error", err, "execution_id", execID)
}
}
func jsonOutBytes(out string) []byte {
b, _ := json.Marshal(map[string]any{"output": out})
return b
}
func jsonErrBytes(format string, args ...any) []byte {
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
return b
}

View File

@@ -0,0 +1,283 @@
package app
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/core/ports/portstest"
)
func newExecDeps(t *testing.T) (*portstest.ExecutionRecorder, *portstest.RecordingExecutor, *ExecutionService) {
t.Helper()
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
rec := portstest.NewExecutionRecorder()
exec := &portstest.RecordingExecutor{}
resolver := &portstest.FakeResolver{Addr: "10.1.1.1", User: "root"}
svc := NewExecutionService(NewPolicyService(store), exec, resolver, rec)
return rec, exec, svc
}
func TestExecutionSubmitRefuseRecordsNothing(t *testing.T) {
rec, exec, svc := newExecDeps(t)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "qm list", SessionID: sessionOK, // host-only on lxc → refuse
})
if res.Decision.Action != DecisionRefuse {
t.Fatalf("action = %q, want refuse", res.Decision.Action)
}
if len(rec.Created) != 0 || len(rec.Queued) != 0 || len(exec.Calls) != 0 {
t.Error("refusal must record nothing and execute nothing")
}
if res.ExecutionID != "" {
t.Error("refusal has no execution id")
}
}
func TestExecutionSubmitAutoRun(t *testing.T) {
rec, exec, svc := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "Filesystem 512-blocks..."}}
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "df -h", Purpose: "check disk", SessionID: sessionOK,
})
if res.Decision.Action != DecisionAuto || res.Err != nil {
t.Fatalf("result = %+v err=%v", res.Decision, res.Err)
}
if res.Output != "Filesystem 512-blocks..." {
t.Errorf("output = %q", res.Output)
}
if len(exec.Calls) != 1 || exec.Calls[0].Command != "df -h" {
t.Errorf("executor calls = %+v", exec.Calls)
}
if len(rec.Created) != 1 {
t.Fatalf("created = %d, want 1", len(rec.Created))
}
created := rec.Created[0]
if created.RiskClass != "read_only" || created.Route != "auto-act" {
t.Errorf("created = %+v", created)
}
if !strings.HasPrefix(created.Action, "run:{") {
t.Errorf("action col = %q", created.Action)
}
if len(rec.Running) != 1 || len(rec.Finalized) != 1 {
t.Errorf("running=%d finalized=%d", len(rec.Running), len(rec.Finalized))
}
fin := rec.Finalized[0]
if fin.Status != "completed" || string(fin.Result) != `{"output":"Filesystem 512-blocks..."}` {
t.Errorf("finalized = %+v", fin)
}
if fin.ExecutionID != res.ExecutionID || rec.Running[0] != res.ExecutionID {
t.Errorf("ids inconsistent: fin=%s run=%s res=%s", fin.ExecutionID, rec.Running[0], res.ExecutionID)
}
if len(rec.Queued) != 0 {
t.Errorf("auto-run must not queue approvals, got %+v", rec.Queued)
}
}
func TestExecutionSubmitAutoRunFailure(t *testing.T) {
rec, exec, svc := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "boom", Err: errors.New("exit 1")}}
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "df -h", SessionID: sessionOK,
})
if res.Err == nil {
t.Fatal("expected error surfaced")
}
fin := rec.Finalized[0]
if fin.Status != "failed" || !strings.Contains(string(fin.Result), "exit 1") {
t.Errorf("finalized = %+v", fin)
}
}
func TestExecutionSubmitResolverFailureFailsExecution(t *testing.T) {
rec, exec, _ := newExecDeps(t)
// A resolver that always errors.
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
failing := &portstest.FakeResolver{Err: errors.New("no IP found")}
svc := NewExecutionService(NewPolicyService(store), exec, failing, rec)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "df -h", SessionID: sessionOK,
})
if res.Err == nil {
t.Fatal("resolver failure must surface")
}
if len(exec.Calls) != 0 {
t.Error("no execution on resolve failure")
}
if rec.Finalized[0].Status != "failed" {
t.Errorf("finalized = %+v, want failed", rec.Finalized[0])
}
}
func TestExecutionSubmitQueue(t *testing.T) {
rec, _, svc := newExecDeps(t)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "systemctl enable caddy", Purpose: "enable service", SessionID: sessionOK,
})
if res.Decision.Action != DecisionQueue {
t.Fatalf("action = %q, want queue", res.Decision.Action)
}
if len(rec.Queued) != 1 {
t.Fatalf("queued = %d, want 1", len(rec.Queued))
}
q := rec.Queued[0]
if q.RiskClass != "config_mutation" || q.SessionID != sessionOK || q.ExecutionID != res.ExecutionID {
t.Errorf("queued = %+v", q)
}
if len(rec.Finalized) != 0 {
t.Error("queued submission must not finalize")
}
}
func TestExecutionSubmitAsync(t *testing.T) {
rec, exec, _ := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "done"}}
// Wrap the executor to block until released.
release := make(chan struct{})
blocking := &blockingExecutor{inner: exec, release: release}
// sleep-classified commands are config_mutation; open the assent
// window so the decision is auto-run, then dispatch async.
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
store.Assent[string(agentID)+"/"+sessionOK] = true
svc := NewExecutionService(NewPolicyService(store), blocking, &portstest.FakeResolver{Addr: "10.0.0.1", User: "root"}, rec)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "sleep 60", SessionID: sessionOK, Async: true,
})
if !res.AsyncStarted {
t.Fatalf("result = %+v, want async started", res)
}
if res.ExecutionID == "" {
t.Fatal("async submission must return the execution id synchronously")
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
rec.MuLock()
n := len(rec.Finalized)
rec.MuUnlock()
if n == 1 {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("async execution did not finalize")
}
type blockingExecutor struct {
inner *portstest.RecordingExecutor
release chan struct{}
}
func (b *blockingExecutor) Run(ctx context.Context, target ports.Target, command string, opts ports.ExecOpts) ports.ExecResult {
<-b.release
return b.inner.Run(ctx, target, command, opts)
}
func TestExecutionSubmitSinkFactoryStreaming(t *testing.T) {
_, exec, svc := newExecDeps(t)
exec.Results = []ports.ExecResult{{Output: "x"}}
var streams []string
flushed := false
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "df -h", SessionID: sessionOK,
SinkFactory: func(ctx context.Context, id domain.UUID, corr string) (func(string, []byte), func()) {
return func(stream string, chunk []byte) { streams = append(streams, stream) },
func() { flushed = true }
},
})
if res.Err != nil {
t.Fatalf("submit: %v", res.Err)
}
if !flushed {
t.Error("sink flush must run after execution")
}
}
func TestExecutionSubmitRecorderErrors(t *testing.T) {
for _, phase := range []string{"create", "queue", "mark", "finalize"} {
t.Run(phase, func(t *testing.T) {
rec, exec, _ := newExecDeps(t)
rec.ErrStubs[phase] = errors.New("db down")
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
svc := NewExecutionService(NewPolicyService(store), exec, &portstest.FakeResolver{Addr: "10.0.0.1", User: "root"}, rec)
// create/mark/finalize fail on the auto path; queue fails on
// the gated path.
cmd := ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "systemctl enable caddy", SessionID: sessionOK,
}
if phase == "create" || phase == "mark" || phase == "finalize" {
cmd.Command = "df -h" // auto-run path
}
res := svc.Submit(context.Background(), cmd)
switch phase {
case "create", "queue":
if res.Err == nil {
t.Errorf("phase %s: expected surfaced error", phase)
}
default:
// mark/finalize failures are logged, not fatal: the
// execution still reports its outcome.
if res.Err != nil && phase == "mark" {
t.Errorf("phase %s: mark failure must not fail submit: %v", phase, res.Err)
}
}
})
}
}
func TestExecutionFinalizeErrorNotFatal(t *testing.T) {
rec, exec, _ := newExecDeps(t)
rec.ErrStubs["finalize"] = errors.New("write failed")
store := portstest.NewGovernanceStore()
store.PlanSessions[sessionOK] = true
svc := NewExecutionService(NewPolicyService(store), exec, &portstest.FakeResolver{Addr: "10.0.0.1", User: "root"}, rec)
res := svc.Submit(context.Background(), ExecutionSubmitCmd{
AgentID: agentID, TargetID: targetID, TargetSlug: "lxc:x",
Command: "df -h", SessionID: sessionOK,
})
if res.Err != nil {
t.Errorf("finalize failure must not fail the submit: %v", res.Err)
}
if res.Output != "" {
t.Errorf("output = %q, want the executed output", res.Output)
}
}
func TestExecutionSubmitHostHint(t *testing.T) {
svc, _ := newPolicySvc(t)
svc.HostHint = func(ctx context.Context, slug string) string { return "host:strong" }
d := svc.Decide(context.Background(), submit("lxc:dns", "pct list", ""))
if d.Action != DecisionRefuse || !strings.Contains(d.Message, "host:strong") {
t.Errorf("decision = %+v, want hint host:strong", d)
}
// Hint returning "" falls back to the default.
svc.HostHint = func(ctx context.Context, slug string) string { return "" }
d = svc.Decide(context.Background(), submit("lxc:dns", "pvesh get /x", ""))
if !strings.Contains(d.Message, "host:hubris or host:strong") {
t.Errorf("default hint missing: %s", d.Message)
}
}

325
internal/core/app/policy.go Normal file
View File

@@ -0,0 +1,325 @@
// Package app — PolicyService: the classify→gate decision pipeline for
// agent command execution. Extracted verbatim-in-order from the MCP
// classifyAndGate path (hexagonal Phase 4); every refusal message and
// gate ordering is preserved. The pure half (classification rules,
// syntax/target validation, window evaluation) lives here; the facts
// that need the store (plan state, windows, dedup, QGA attribute) come
// through ports.GovernanceStore.
package app
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/policy"
)
// PolicyService evaluates whether a submitted command auto-runs, queues
// for operator approval, or is refused outright.
type PolicyService struct {
store ports.GovernanceStore
// HostHint, when wired at composition time, resolves a better
// Proxmox-host suggestion for host-only command refusals. Optional;
// the default hint is used when nil or when it returns "".
HostHint func(ctx context.Context, targetSlug string) string
}
// NewPolicyService wires the service over the governance read model.
func NewPolicyService(store ports.GovernanceStore) *PolicyService {
return &PolicyService{store: store}
}
// PolicySubmitInput is one gating evaluation request.
type PolicySubmitInput struct {
AgentID domain.UUID
TargetID domain.UUID
TargetSlug string
Command string
Purpose string
DeclaredRisk string
SessionID string
}
// Decision actions.
const (
DecisionRefuse = "refuse" // return Message to the agent; nothing recorded
DecisionAuto = "auto_run" // execute now (via window named in Route)
DecisionQueue = "queue_approval" // persist + ask the operator
)
// PolicyDecision is the outcome of the gate pipeline.
type PolicyDecision struct {
Action string // DecisionRefuse | DecisionAuto | DecisionQueue
RiskClass string
Route string // "auto-act" | "escalate" — the classifications-table route
Message string // for DecisionRefuse: the agent-facing refusal; else ""
// AutoViaWindow names why an auto-run is allowed for non-read-only
// classes: "", "assent", or "destructive". read_only/reversible_low
// auto-run unattended by policy, not by window.
AutoViaWindow string
}
// Classify computes the risk class: the text classifier's verdict, kept
// at or above the agent's declaration (a declaration can only escalate),
// then transport-aware escalation — a read classified command on an LXC
// target that touches config paths (/opt/, /etc/, /var/lib/) escalates
// to config_mutation, because SSH-ing into a container to read config is
// riskier than the same read via pct exec from the host. Log-file reads
// (tail/head/cat/less/journalctl on *.log or */logs/*) are exempt.
func (s *PolicyService) Classify(command, declaredRisk, targetSlug string) string {
riskClass := policy.ClassifyCommand(command, declaredRisk)
if riskClass == policy.RiskReadOnly && strings.HasPrefix(targetSlug, "lxc:") {
if !IsLogInspectionRead(command) {
if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") {
riskClass = policy.RiskConfigMutation
}
}
}
return riskClass
}
// Decide runs the full gate pipeline in order: classify, plan-first,
// syntax, host-only command, host/lxc-only command, VM guest-agent
// pre-flight, duplicate-pending, approval-flood, then the route
// evaluation (risk class × windows). Returns the decision; the caller
// records and dispatches per Action.
func (s *PolicyService) Decide(ctx context.Context, in PolicySubmitInput) PolicyDecision {
riskClass := s.Classify(in.Command, in.DeclaredRisk, in.TargetSlug)
// P1 plan-first gate: every task must propose a plan before any `run`,
// read-only or not. Without this gate the "MANDATORY TASK FLOW" is
// unenforceable prose — weaker models skip propose_plan and leave the
// operator with 23 individual approvals and no plan. sessionID == ""
// means a direct MCP call with no nomos session — no-op there.
if in.SessionID != "" && !s.store.SessionHasPlan(ctx, in.SessionID) {
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: "No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists."}
}
// Command syntax validation: catch LLM-generated bash bugs before they
// hit the shell.
if syntaxErr := ValidateCommandSyntax(in.Command); syntaxErr != "" {
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: syntaxErr}
}
// Target validation: host-only commands (qm, pct, pvesh, iptables)
// must not be dispatched against lxc:/vm: targets.
if cmdPrefix, hostOnly := HostOnlyCommand(in.Command); hostOnly && !strings.HasPrefix(in.TargetSlug, "host:") {
hostSuggestion := s.proxmoxHostHint(ctx, in.TargetSlug)
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf("Cannot run %q on %s — %s is a Proxmox host command. Use target %s instead.",
cmdPrefix, in.TargetSlug, cmdPrefix, hostSuggestion)}
}
// systemctl and docker work on hosts and LXCs, but not VMs.
if cmdPrefix, hostLxc := HostLxcCommand(in.Command); hostLxc {
if !strings.HasPrefix(in.TargetSlug, "host:") && !strings.HasPrefix(in.TargetSlug, "lxc:") {
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf("Cannot run %q on %s — %s only works on host:* or lxc:* targets.",
cmdPrefix, in.TargetSlug, cmdPrefix)}
}
}
// VM transport pre-flight: qm guest exec requires the QEMU guest agent
// to be running inside the VM; if it's not, the execution would queue
// and never execute.
if strings.HasPrefix(in.TargetSlug, "vm:") {
if attrs, err := s.store.EntityAttributes(ctx, in.TargetID); err == nil {
if qga, ok := attrs["qemu_guest_agent"]; ok {
qgaStr, _ := qga.(string)
if qgaStr == "not_running" || qgaStr == "" {
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf(
"run on %s blocked: QEMU guest agent is not running (%s). qm guest exec cannot reach this VM. Start the agent inside the guest first (e.g. via SSH/systemctl start qemu-guest-agent), then re-run. If the agent is running but the entity attribute is stale, update it with update_entity_attributes(slug=%s, attributes={\"qemu_guest_agent\":\"running\"}).",
in.TargetSlug, qgaStr, in.TargetSlug)}
}
}
}
}
// Dedup: an identical pending command blocks a re-request — stops a
// tool-calling loop from queuing the same approval repeatedly.
runParams := RunActionParams(in.Command, in.Purpose)
if existingID, dup := s.store.PendingDuplicate(ctx, in.TargetID, runParams); dup {
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", in.TargetSlug, existingID)}
}
// Approval-flood gate: for config_mutation with no assent window and
// already one pending approval in the session, refuse — the operator
// should see ONE approval (the plan), not N.
if riskClass == policy.RiskConfigMutation && in.SessionID != "" && !s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) {
if s.store.PendingApprovalCount(ctx, in.SessionID) > 0 {
return PolicyDecision{Action: DecisionRefuse, RiskClass: riskClass, Message: "An approval is already pending for this plan. Present the plan and its steps to the operator, then STOP and wait for their approval (\"approved\", \"yes\", \"go ahead\"). Do not call run again until the operator responds — after approval, all config_mutation commands will auto-run."}
}
}
return s.route(ctx, in, riskClass)
}
// route evaluates risk class against the auto-run windows.
func (s *PolicyService) route(ctx context.Context, in PolicySubmitInput, riskClass string) PolicyDecision {
// read_only and reversible_low run unattended, as seeds/policy.yaml
// declares. reversible_low can only arise when the agent declares it
// on a command the classifier already scored read_only (the classifier
// keeps the higher class), so auto-running it is no more permissive
// than the read_only branch — candor is never punished.
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act"}
}
// Assent window: the operator approved the plan in this session;
// config_mutation within the window auto-runs.
if riskClass == policy.RiskConfigMutation && s.store.AssentWindowActive(ctx, in.AgentID, in.SessionID) {
return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act", AutoViaWindow: "assent"}
}
// Destructive window: a narrow, target-scoped grant opened only by an
// explicit typed confirmation on this same target.
if riskClass == policy.RiskDestructive && s.store.DestructiveWindowActive(ctx, in.AgentID, in.TargetSlug, in.SessionID) {
return PolicyDecision{Action: DecisionAuto, RiskClass: riskClass, Route: "auto-act", AutoViaWindow: "destructive"}
}
return PolicyDecision{Action: DecisionQueue, RiskClass: riskClass, Route: "escalate"}
}
// proxmoxHostHint suggests a Proxmox host target for host-only command
// refusals. Lazily resolved only when such a refusal fires.
func (s *PolicyService) proxmoxHostHint(ctx context.Context, targetSlug string) string {
if s.HostHint != nil {
if hint := s.HostHint(ctx, targetSlug); hint != "" {
return hint
}
}
return "host:hubris or host:strong"
}
// RunActionParams renders the executions.action column value for a run
// submission: "run:{json command+purpose}".
func RunActionParams(command, purpose string) string {
b, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
return "run:" + string(b)
}
// ─── Pure classification helpers (moved from internal/mcp) ────────────
// hostOnlyCommands maps command prefixes only valid on Proxmox host
// targets. Running these against an lxc: or vm: target always fails with
// "command not found" and wastes a turn.
var hostOnlyCommands = map[string]bool{
"qm": true,
"pct": true,
"pvesh": true,
"iptables": true,
}
// hostLxcCommands maps command prefixes valid on host:* and lxc:* but
// not vm:*.
var hostLxcCommands = map[string]bool{
"systemctl": true,
"docker": true,
}
// HostOnlyCommand checks whether the leading word of cmd is a host-only
// command. Returns the command word and true if it can only run on a
// host: target.
func HostOnlyCommand(cmd string) (string, bool) {
first := leadingCommandWord(cmd)
return first, hostOnlyCommands[first]
}
// HostLxcCommand checks whether the leading word of cmd is a command
// valid on host:* and lxc:* targets but not vm:*.
func HostLxcCommand(cmd string) (string, bool) {
first := leadingCommandWord(cmd)
return first, hostLxcCommands[first]
}
// leadingCommandWord extracts the first word of the actual command,
// seeing through `bash -c '...'` wrappers and stripping paths
// (/usr/sbin/qm → qm).
func leadingCommandWord(cmd string) string {
trimmed := strings.TrimSpace(cmd)
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return ""
}
first := parts[0]
if (first == "bash" || first == "sh") && len(parts) >= 3 && parts[1] == "-c" {
actual := strings.Trim(strings.Join(parts[2:], " "), "'\"")
if inner := strings.Fields(actual); len(inner) > 0 {
first = inner[0]
}
}
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
first = first[idx+1:]
}
return first
}
// IsLogInspectionRead returns true for a safe read-only operation on a
// log file — tail, head, cat, less, or journalctl with a .log or /logs/
// path. Exempt from the LXC transport escalation: log inspection is the
// most common debugging action.
func IsLogInspectionRead(cmd string) bool {
trimmed := strings.TrimSpace(cmd)
for _, prefix := range []string{"tail ", "head ", "cat ", "less ", "journalctl "} {
if strings.HasPrefix(trimmed, prefix) {
if strings.Contains(trimmed, ".log") || strings.Contains(trimmed, "/logs/") {
return true
}
}
}
return false
}
// ValidateCommandSyntax checks for common LLM-generated bash errors that
// always fail at the shell. Returns an error message or "".
func ValidateCommandSyntax(cmd string) string {
if strings.Contains(cmd, "\\n") {
return fmt.Sprintf("Command contains literal '\\n' — use ';' or '&&' between commands, not a literal backslash-n. Command: %q", cmd)
}
if andBackslashRe.MatchString(cmd) {
return fmt.Sprintf("Command contains '&&' followed by a literal backslash-newline — remove the backslash or use ';' instead. Command: %q", cmd)
}
trimmed := strings.TrimSpace(cmd)
if strings.HasSuffix(trimmed, "\\") {
return fmt.Sprintf("Command ends with a backslash but has nothing after it to continue. Remove the trailing '\\'. Command: %q", cmd)
}
if flagSpaceRe.MatchString(cmd) {
return fmt.Sprintf("Command has a space between a flag and its value (e.g. 'head - n' instead of 'head -n'). Remove the space. Command: %q", cmd)
}
return ""
}
var (
andBackslashRe = regexp.MustCompile(`&&\s*\\\s*\n`)
// flagSpaceRe catches a space BETWEEN the dash and the flag's value
// ("head - n 5"), the LLM typo the gate exists for. The pre-extraction
// pattern `(-\w)\s+\w` matched the VALID spelling ("tail -n 3 file")
// and missed the typo — every properly-written `tail -n` was refused
// with a bogus message while "head - n" sailed through. Surfaced by
// the Phase 4 gating-matrix tests; fixed here.
flagSpaceRe = regexp.MustCompile(`\b(head|tail|grep|sed|awk|sort|uniq|wc)\s+-\s+\w`)
sleepRe = regexp.MustCompile(`\bsleep\s+\d`)
pollRe = regexp.MustCompile(`\bwhile\b.*\bsleep\b`)
waitRe = regexp.MustCompile(`\bwait\s+\d|[&;]\s*wait\b`)
)
// IsLongRunningCommand detects shell commands containing sleep, wait, or
// poll loops that indicate the command will exceed the MCP client
// timeout (120s). These dispatch async (server-side continuation).
func IsLongRunningCommand(cmd string) bool {
cmd = strings.TrimSpace(cmd)
if sleepRe.MatchString(cmd) {
return true
}
if pollRe.MatchString(cmd) {
return true
}
if waitRe.MatchString(cmd) {
return true
}
return false
}

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