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