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.
211 lines
7.5 KiB
Go
211 lines
7.5 KiB
Go
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
|
|
}
|