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

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
@@ -18,7 +19,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.ExecutionService) []toolReg {
return []toolReg{
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
// All mutations now route through `run`. The handler functions
@@ -49,7 +50,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
return classifyAndGate(ctx, pool, execSvc, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
}},
// docker_exec wraps a command inside a Docker container on an LXC.
// Resolves the LXC, looks up the pve_id, and runs via
@@ -101,7 +102,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
noQuote := strings.ReplaceAll(command, "'", "'\\''")
dockerCmd := fmt.Sprintf("docker exec %s sh -c '%s'", container, noQuote)
return classifyAndGate(ctx, pool, agentID, targetID, lxcSlug, dockerCmd, purpose, declaredRisk, sessionID), nil
return classifyAndGate(ctx, pool, execSvc, agentID, targetID, lxcSlug, dockerCmd, purpose, declaredRisk, sessionID), nil
}},
// inspect_path is the bulk fact-gathering tool from
// plans/2026-07-18-session-review-three-sessions.md P1.5.
@@ -668,7 +669,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
}
command := fmt.Sprintf("systemctl restart %s", service)
purpose := fmt.Sprintf("restart %s on %s", service, targetSlug)
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, "config_mutation", sessionID), nil
return classifyAndGate(ctx, pool, execSvc, agentID, targetID, targetSlug, command, purpose, "config_mutation", sessionID), nil
}},
{tool: &mcp.Tool{Name: "push_file", Description: "Copy a file from a Proxmox host into an LXC container via `pct push`. The source path must already exist on the Proxmox host that owns the LXC (stage it there first via `run` on the host, e.g. with scp/curl/wget). Classified config_mutation — requires operator approval. Prefer this over manual 3-hop SSH piping (`cat | ssh | pct exec tee`), which repeatedly drops into partial-write/text-file-busy states.",
InputSchema: objSchema(
@@ -725,7 +726,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
}
}
purpose := fmt.Sprintf("push %s into %s at %s", sourcePath, targetSlug, destPath)
return classifyAndGate(ctx, pool, agentID, hostEntityID, "host:"+hostSlug, cmd, purpose, "config_mutation", sessionID), nil
return classifyAndGate(ctx, pool, execSvc, agentID, hostEntityID, "host:"+hostSlug, cmd, purpose, "config_mutation", sessionID), nil
}},
// ── Approval Management (replaces Matrix notifier) ─────────────
{tool: &mcp.Tool{Name: "list_approvals", Description: "List pending and recent approvals. Returns approval ID, action, risk class, target slug, status, and timing. Filter by status (pending, approved, denied) or entity slug to scope. Use after a `run` returns 'requires approval' to see what's pending so you can present it to the operator for a decision.",