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:
323
internal/adapters/postgres/governance.go
Normal file
323
internal/adapters/postgres/governance.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/core/domain"
|
||||
"github.com/dtoro/oikos/internal/core/ports"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GovernanceRepo implements ports.GovernanceStore over the postgres
|
||||
// pool. The SQL is lifted from the MCP classifyAndGate path (Phase 4
|
||||
// convergence); fail-open/fail-closed semantics per gate are preserved
|
||||
// and documented on the port.
|
||||
type GovernanceRepo struct {
|
||||
pool *Pool
|
||||
}
|
||||
|
||||
var _ ports.GovernanceStore = (*GovernanceRepo)(nil)
|
||||
|
||||
// NewGovernanceRepo builds the governance read model.
|
||||
func NewGovernanceRepo(pool *Pool) *GovernanceRepo { return &GovernanceRepo{pool: pool} }
|
||||
|
||||
// SessionHasPlan fails open (true) on query error — a transient DB issue
|
||||
// must not block an otherwise-valid run.
|
||||
func (g *GovernanceRepo) SessionHasPlan(ctx context.Context, sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return true // no session → no gate (direct MCP call from a script)
|
||||
}
|
||||
var count int
|
||||
if err := g.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM session_plan_steps
|
||||
WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID).Scan(&count); err != nil {
|
||||
return true
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// AssentWindowActive checks the session-scoped assent window key set by
|
||||
// chat assent and the approval-decide path.
|
||||
func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain.UUID, sessionID string) bool {
|
||||
if agentID == "" || sessionID == "" {
|
||||
return false // fail closed
|
||||
}
|
||||
return g.windowActive(ctx, "assent_window.agent:"+string(agentID)+".session:"+sessionID)
|
||||
}
|
||||
|
||||
// DestructiveWindowActive checks the target+session-scoped destructive
|
||||
// window key. Key format must match cmd/nomos/store.go's
|
||||
// openDestructiveWindow — both processes read/write the same rows.
|
||||
func (g *GovernanceRepo) DestructiveWindowActive(ctx context.Context, agentID domain.UUID, targetSlug, sessionID string) bool {
|
||||
if agentID == "" || targetSlug == "" || sessionID == "" {
|
||||
return false // fail closed
|
||||
}
|
||||
return g.windowActive(ctx,
|
||||
"destructive_window.agent:"+string(agentID)+".target:"+targetSlug+".session:"+sessionID)
|
||||
}
|
||||
|
||||
func (g *GovernanceRepo) windowActive(ctx context.Context, key string) bool {
|
||||
var expiresStr string
|
||||
err := g.pool.QueryRow(ctx,
|
||||
"SELECT value FROM autonomy_settings WHERE key = $1", key).Scan(&expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().Before(expires)
|
||||
}
|
||||
|
||||
// PendingApprovalCount returns the session's executions at pending_approval.
|
||||
func (g *GovernanceRepo) PendingApprovalCount(ctx context.Context, sessionID string) int {
|
||||
var n int
|
||||
if err := g.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM nomos_plan_executions pe
|
||||
JOIN executions ex ON ex.entity_id = pe.execution_id
|
||||
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
|
||||
sessionID).Scan(&n); err != nil {
|
||||
return 0 // fail open for the flood gate: don't block on a flake
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PendingDuplicate returns the newest identical pending execution on the
|
||||
// target, if any.
|
||||
func (g *GovernanceRepo) PendingDuplicate(ctx context.Context, targetID domain.UUID, action string) (domain.UUID, bool) {
|
||||
var existingID string
|
||||
err := g.pool.QueryRow(ctx, `
|
||||
SELECT e.id::text FROM entities e
|
||||
JOIN executions ex ON ex.entity_id = e.id
|
||||
WHERE e.type = 'execution' AND ex.target_entity_id = $1
|
||||
AND ex.action = $2 AND ex.status = 'pending_approval'
|
||||
ORDER BY e.created_at DESC LIMIT 1`,
|
||||
mustUUID(targetID), action).Scan(&existingID)
|
||||
if err != nil || existingID == "" {
|
||||
return "", false
|
||||
}
|
||||
return domain.UUID(existingID), true
|
||||
}
|
||||
|
||||
// EntityAttributes loads one entity's attributes map.
|
||||
func (g *GovernanceRepo) EntityAttributes(ctx context.Context, targetID domain.UUID) (map[string]any, error) {
|
||||
var rawAttrs []byte
|
||||
if err := g.pool.QueryRow(ctx,
|
||||
`SELECT attributes FROM entities WHERE id = $1`, mustUUID(targetID)).Scan(&rawAttrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var attrs map[string]any
|
||||
if err := json.Unmarshal(rawAttrs, &attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
// ResolveProxmoxHostSlug resolves the Proxmox host owning a guest slug
|
||||
// (refusal-hint input for host-only commands). Removed — importing
|
||||
// internal/remote from the postgres adapter forms an import cycle; the
|
||||
// hint is wired at composition time via PolicyService.HostHint instead.
|
||||
|
||||
// ExecRunRepo implements ports.ExecutionRecorder over the postgres
|
||||
// pool: the write half of the run-submission lifecycle, lifted from the
|
||||
// MCP classifyAndGate / autoRun / createApproval /
|
||||
// markSessionAwaitingApproval paths (Phase 4 convergence).
|
||||
type ExecRunRepo struct {
|
||||
pool *Pool
|
||||
}
|
||||
|
||||
var _ ports.ExecutionRecorder = (*ExecRunRepo)(nil)
|
||||
|
||||
// NewExecRunRepo builds the recorder.
|
||||
func NewExecRunRepo(pool *Pool) *ExecRunRepo { return &ExecRunRepo{pool: pool} }
|
||||
|
||||
// CreateRun writes the execution entity + row, graph edges, session
|
||||
// link, classification row, and audit entry.
|
||||
func (r *ExecRunRepo) CreateRun(ctx context.Context, in ports.CreateRunInput) error {
|
||||
id := mustUUID(in.ExecutionID)
|
||||
execName := "run on " + in.TargetSlug + " (" + id.String() + ")"
|
||||
execSlug := "exec:" + in.TargetSlug + ":" + id.String()
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
||||
id, execSlug, execName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id)
|
||||
VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
||||
id, mustUUID(in.TargetID), in.Action, in.RiskClass, in.CorrelationID, agentUUID(in.AgentID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
||||
id, mustUUID(in.TargetID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
auditSessionID := sessionUUID(in.SessionID)
|
||||
if in.SessionID != "" {
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||
FROM entities t WHERE t.slug = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||
id, "task:"+in.SessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Link execution to session for auto-continuation.
|
||||
if auditSessionID != nil {
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO nomos_plan_executions (execution_id, session_id)
|
||||
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, id, *auditSessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-classify: write the classification decision.
|
||||
classID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'classification', $3, '{}')`,
|
||||
classID, "classification:"+classID.String(), "classification for "+execSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO classifications (entity_id, action, risk_class, route, reasoning, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
classID, in.Action, in.RiskClass, in.Route, string(in.ClassReasoning), in.CorrelationID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE executions SET classification_id = $2 WHERE entity_id = $1`, id, classID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'precedes', '{"by":"nomos"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'precedes' AND valid_to IS NULL)`,
|
||||
classID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Audit the run request (every run, auto or queued).
|
||||
q := sqlcgen.New(r.pool)
|
||||
return observability.Audit(ctx, q, "agent", "nomos", "run",
|
||||
&id, "POST", "/mcp", in.CorrelationID, auditSessionID, map[string]any{
|
||||
"command": in.Command, "target": in.TargetSlug,
|
||||
"risk_class": in.RiskClass, "purpose": in.Purpose,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkRunning stamps status + started_at now.
|
||||
func (r *ExecRunRepo) MarkRunning(ctx context.Context, id domain.UUID) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`UPDATE executions SET status='running', started_at=$2 WHERE entity_id=$1`,
|
||||
mustUUID(id), time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// Finalize stamps the terminal status, result payload, duration, and
|
||||
// completion time.
|
||||
func (r *ExecRunRepo) Finalize(ctx context.Context, in ports.FinalizeExecutionInput) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4,
|
||||
started_at=$5, completed_at=now()
|
||||
WHERE entity_id=$1`,
|
||||
mustUUID(in.ExecutionID), in.Status, string(in.Result),
|
||||
int(time.Since(in.StartedAt).Milliseconds()), in.StartedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// QueueApproval creates the approval row, flips the execution to
|
||||
// pending_approval, emits approval.created, and marks the session
|
||||
// awaiting_input.
|
||||
func (r *ExecRunRepo) QueueApproval(ctx context.Context, in ports.QueueApprovalInput) error {
|
||||
id := mustUUID(in.ExecutionID)
|
||||
payload, _ := json.Marshal(map[string]any{"action": in.Action, "params": in.Params, "execution_id": in.ExecutionID})
|
||||
// approvals.entity_id is PK + FK to entities(id). Reuse the
|
||||
// execution's entity so the FK is satisfied — a fresh UUID here has no
|
||||
// matching entities row and the INSERT silently fails, orphaning the
|
||||
// execution (one execution maps to at most one approval, so the 1:1
|
||||
// identity holds).
|
||||
subject := mustUUID(in.TargetID)
|
||||
if err := sqlcgen.New(r.pool).InsertApproval(ctx, sqlcgen.InsertApprovalParams{
|
||||
EntityID: id,
|
||||
SubjectEntityID: &subject,
|
||||
Action: in.Action,
|
||||
RiskClass: in.RiskClass,
|
||||
Kind: "execution",
|
||||
Payload: payload,
|
||||
TokenHash: nil,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE executions SET approval_id = $1, status='pending_approval', risk_class=$2 WHERE entity_id = $1`,
|
||||
id, in.RiskClass); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Operator-facing SSE event: a gated action now awaits a decision.
|
||||
q := sqlcgen.New(r.pool)
|
||||
_ = observability.Event(ctx, q, "approval.created", &id, "warning", "mcp", "",
|
||||
map[string]any{"action": in.Action, "params": in.Params, "risk_class": in.RiskClass})
|
||||
|
||||
// Flip the session to awaiting_input so boards and the idle sweep see
|
||||
// the task as blocked on the operator, not silently working. No-op for
|
||||
// direct MCP calls with no nomos session.
|
||||
if in.SessionID == "" || in.SessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now()
|
||||
WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, in.SessionID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return nil
|
||||
}
|
||||
var taskEntID *uuid.UUID
|
||||
var e uuid.UUID
|
||||
if qerr := r.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, in.SessionID).Scan(&e); qerr == nil && e != uuid.Nil {
|
||||
taskEntID = &e
|
||||
}
|
||||
return observability.Event(ctx, q, "task.status", taskEntID, "info", "nomos", in.SessionID,
|
||||
map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"})
|
||||
}
|
||||
|
||||
// agentUUID maps a domain agent id to pg uuid (nil-safe).
|
||||
func agentUUID(id domain.UUID) any {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
return mustUUID(id)
|
||||
}
|
||||
|
||||
// sessionUUID parses a session id for audit linkage; nil when absent or
|
||||
// ephemeral.
|
||||
func sessionUUID(sessionID string) *uuid.UUID {
|
||||
if sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
sid, err := uuid.Parse(sessionID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &sid
|
||||
}
|
||||
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
|
||||
}
|
||||
283
internal/core/app/execution_test.go
Normal file
283
internal/core/app/execution_test.go
Normal 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
325
internal/core/app/policy.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,80 @@ type ApprovalRepository interface {
|
||||
|
||||
Decide(ctx context.Context, input ApprovalDecideInput) (domain.Approval, error)
|
||||
}
|
||||
|
||||
// GovernanceStore is the policy read model: the facts the gating decision
|
||||
// needs that live outside the command text — session plan state, assent/
|
||||
// destructive windows, duplicate pending executions, and target entity
|
||||
// attributes. Implemented by the postgres adapter; faked in portstest.
|
||||
type GovernanceStore interface {
|
||||
// SessionHasPlan reports whether the session has any non-replaced plan
|
||||
// step (the plan-first gate). Fails open on store errors — a flake
|
||||
// must not block otherwise-valid work.
|
||||
SessionHasPlan(ctx context.Context, sessionID string) bool
|
||||
// AssentWindowActive reports a live operator-assent grant for this
|
||||
// agent+session (config_mutation auto-run scope).
|
||||
AssentWindowActive(ctx context.Context, agentID domain.UUID, sessionID string) bool
|
||||
// DestructiveWindowActive reports a live explicitly-confirmed
|
||||
// destructive grant for this agent+target+session.
|
||||
DestructiveWindowActive(ctx context.Context, agentID domain.UUID, targetSlug, sessionID string) bool
|
||||
// PendingApprovalCount counts this session's executions stuck at
|
||||
// pending_approval (the approval-flood gate).
|
||||
PendingApprovalCount(ctx context.Context, sessionID string) int
|
||||
// PendingDuplicate returns the execution id of an identical command
|
||||
// already queued for approval on this target, if any.
|
||||
PendingDuplicate(ctx context.Context, targetID domain.UUID, action string) (domain.UUID, bool)
|
||||
// EntityAttributes loads one entity's attributes map (VM QEMU-guest-
|
||||
// agent state and similar gating inputs).
|
||||
EntityAttributes(ctx context.Context, targetID domain.UUID) (map[string]any, error)
|
||||
}
|
||||
|
||||
// CreateRunInput records one `run` submission in the execution ledger:
|
||||
// the execution entity + row, its graph edges, the classification
|
||||
// decision, and the audit entry — one transaction. Written before the
|
||||
// route executes (auto-run or queue), exactly as the pre-service
|
||||
// classifyAndGate path did.
|
||||
type CreateRunInput struct {
|
||||
ExecutionID domain.UUID
|
||||
TargetID domain.UUID
|
||||
TargetSlug string
|
||||
AgentID domain.UUID
|
||||
SessionID string
|
||||
Command string
|
||||
Purpose string
|
||||
Action string // "run:{json params}" action column value
|
||||
RiskClass string
|
||||
Route string // "auto-act" | "escalate"
|
||||
ClassReasoning []byte
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// QueueApprovalInput records the operator-approval ask for a gated
|
||||
// execution: the approval row, the execution status flip, and the
|
||||
// session's awaiting_input transition (with its task.status event).
|
||||
type QueueApprovalInput struct {
|
||||
ExecutionID domain.UUID
|
||||
TargetID domain.UUID
|
||||
TargetSlug string
|
||||
Action string
|
||||
Params string
|
||||
RiskClass string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// FinalizeExecutionInput closes an auto-run execution with full timing.
|
||||
type FinalizeExecutionInput struct {
|
||||
ExecutionID domain.UUID
|
||||
Status string // "completed" | "failed"
|
||||
Result []byte
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// ExecutionRecorder persists the run-submission lifecycle. The service
|
||||
// owns ordering and the decision; the adapter owns the SQL (entity row,
|
||||
// executions row, edges, classifications, approvals, audit, events).
|
||||
type ExecutionRecorder interface {
|
||||
CreateRun(ctx context.Context, input CreateRunInput) error
|
||||
MarkRunning(ctx context.Context, executionID domain.UUID) error
|
||||
Finalize(ctx context.Context, input FinalizeExecutionInput) error
|
||||
QueueApproval(ctx context.Context, input QueueApprovalInput) error
|
||||
}
|
||||
|
||||
@@ -485,3 +485,145 @@ func (r *SeedRepo) IngestKnowledge(_ context.Context, file string, content []byt
|
||||
func (r *SeedRepo) Export(_ context.Context) (map[string][]byte, error) {
|
||||
return r.ExportFiles, r.ExportErr
|
||||
}
|
||||
|
||||
// GovernanceStore is an in-memory ports.GovernanceStore. Every fact the
|
||||
// gating decision needs is settable per test; the fail-open/fail-closed
|
||||
// semantics of the real store's error paths are simulated via Err.
|
||||
type GovernanceStore struct {
|
||||
mu sync.Mutex
|
||||
// PlanSessions: sessionIDs that have a plan (plan-first gate).
|
||||
PlanSessions map[string]bool
|
||||
// Assent: agentID+"/"+sessionID keys with a live assent window.
|
||||
Assent map[string]bool
|
||||
// Destructive: agentID+"/"+targetSlug+"/"+sessionID keys with a live
|
||||
// destructive window.
|
||||
Destructive map[string]bool
|
||||
// PendingApprovals counts per sessionID (flood gate).
|
||||
PendingApprovals map[string]int
|
||||
// Duplicates: targetID+action key → existing execution id.
|
||||
Duplicates map[string]domain.UUID
|
||||
// Attrs per entity id (VM guest-agent state etc.).
|
||||
Attrs map[domain.UUID]map[string]any
|
||||
}
|
||||
|
||||
// NewGovernanceStore builds an empty governance store.
|
||||
func NewGovernanceStore() *GovernanceStore {
|
||||
return &GovernanceStore{
|
||||
PlanSessions: make(map[string]bool),
|
||||
Assent: make(map[string]bool),
|
||||
Destructive: make(map[string]bool),
|
||||
PendingApprovals: make(map[string]int),
|
||||
Duplicates: make(map[string]domain.UUID),
|
||||
Attrs: make(map[domain.UUID]map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// SessionHasPlan reports whether the session has a plan.
|
||||
func (g *GovernanceStore) SessionHasPlan(_ context.Context, sessionID string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.PlanSessions[sessionID]
|
||||
}
|
||||
|
||||
// AssentWindowActive reports a live assent grant.
|
||||
func (g *GovernanceStore) AssentWindowActive(_ context.Context, agentID domain.UUID, sessionID string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.Assent[string(agentID)+"/"+sessionID]
|
||||
}
|
||||
|
||||
// DestructiveWindowActive reports a live destructive grant.
|
||||
func (g *GovernanceStore) DestructiveWindowActive(_ context.Context, agentID domain.UUID, targetSlug, sessionID string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.Destructive[string(agentID)+"/"+targetSlug+"/"+sessionID]
|
||||
}
|
||||
|
||||
// PendingApprovalCount returns the session's pending-approval count.
|
||||
func (g *GovernanceStore) PendingApprovalCount(_ context.Context, sessionID string) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.PendingApprovals[sessionID]
|
||||
}
|
||||
|
||||
// PendingDuplicate returns the queued duplicate execution, if any.
|
||||
func (g *GovernanceStore) PendingDuplicate(_ context.Context, targetID domain.UUID, action string) (domain.UUID, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
id, ok := g.Duplicates[string(targetID)+"\x00"+action]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// EntityAttributes returns the entity's attributes (nil when unknown).
|
||||
func (g *GovernanceStore) EntityAttributes(_ context.Context, targetID domain.UUID) (map[string]any, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.Attrs[targetID], nil
|
||||
}
|
||||
|
||||
// ExecutionRecorder records the submission lifecycle for assertion.
|
||||
type ExecutionRecorder struct {
|
||||
mu sync.Mutex
|
||||
Created []ports.CreateRunInput
|
||||
Queued []ports.QueueApprovalInput
|
||||
Running []domain.UUID
|
||||
Finalized []ports.FinalizeExecutionInput
|
||||
// ErrStubs keyed by phase: "create", "queue", "mark", "finalize".
|
||||
ErrStubs map[string]error
|
||||
}
|
||||
|
||||
// NewExecutionRecorder builds a fresh recorder.
|
||||
func NewExecutionRecorder() *ExecutionRecorder {
|
||||
return &ExecutionRecorder{ErrStubs: make(map[string]error)}
|
||||
}
|
||||
|
||||
// CreateRun records the submission.
|
||||
func (r *ExecutionRecorder) CreateRun(_ context.Context, in ports.CreateRunInput) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.ErrStubs["create"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.Created = append(r.Created, in)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRunning records the running transition.
|
||||
func (r *ExecutionRecorder) MarkRunning(_ context.Context, id domain.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.ErrStubs["mark"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.Running = append(r.Running, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finalize records the terminal write.
|
||||
func (r *ExecutionRecorder) Finalize(_ context.Context, in ports.FinalizeExecutionInput) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.ErrStubs["finalize"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.Finalized = append(r.Finalized, in)
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueApproval records the approval ask.
|
||||
func (r *ExecutionRecorder) QueueApproval(_ context.Context, in ports.QueueApprovalInput) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if err := r.ErrStubs["queue"]; err != nil {
|
||||
return err
|
||||
}
|
||||
r.Queued = append(r.Queued, in)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MuLock exposes the internal mutex for tests that need to inspect state
|
||||
// racing the async dispatch goroutine.
|
||||
func (r *ExecutionRecorder) MuLock() { r.mu.Lock() }
|
||||
|
||||
// MuUnlock releases the internal mutex.
|
||||
func (r *ExecutionRecorder) MuUnlock() { r.mu.Unlock() }
|
||||
|
||||
@@ -105,7 +105,11 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
|
||||
&portstest.FakeProvisioner{}, &portstest.FakeResolver{},
|
||||
repo, db.NewRelRepo(pool))
|
||||
seeds := app.NewSeedService(db.NewSeedRepo(pool))
|
||||
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds)
|
||||
execSvc := app.NewExecutionService(
|
||||
app.NewPolicyService(portstest.NewGovernanceStore()),
|
||||
&portstest.RecordingExecutor{}, &portstest.FakeResolver{Addr: "127.0.0.1"},
|
||||
portstest.NewExecutionRecorder())
|
||||
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds, execSvc)
|
||||
}
|
||||
|
||||
// testAuthToken is the static bearer token devConfig() configures. There is
|
||||
|
||||
@@ -74,6 +74,9 @@ type Server struct {
|
||||
provisioning *app.ProvisioningService
|
||||
// seeds regenerates seed YAMLs for the export endpoint (Phase 7).
|
||||
seeds *app.SeedService
|
||||
// execSvc is the run-submission use-case (Phase 4): PolicyService
|
||||
// gating + auto-run/queue dispatch, shared by the MCP tools.
|
||||
execSvc *app.ExecutionService
|
||||
}
|
||||
|
||||
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
|
||||
@@ -83,7 +86,7 @@ type Server struct {
|
||||
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
||||
// before closing the pool — otherwise the held connection never releases
|
||||
// and pool.Close() deadlocks.
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService) http.Handler {
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) http.Handler {
|
||||
s := &Server{
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
@@ -96,6 +99,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
|
||||
relService: relService,
|
||||
provisioning: provisioning,
|
||||
seeds: seeds,
|
||||
execSvc: execSvc,
|
||||
}
|
||||
|
||||
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
||||
@@ -339,7 +343,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
|
||||
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
|
||||
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
|
||||
}
|
||||
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService))
|
||||
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService, s.execSvc))
|
||||
|
||||
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
||||
target, _ := url.Parse(nomosURL)
|
||||
@@ -944,10 +948,10 @@ main();
|
||||
|
||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService) error {
|
||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) error {
|
||||
srv := &http.Server{
|
||||
Addr: cfg.APIListen,
|
||||
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds),
|
||||
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds, execSvc),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) str
|
||||
t.Helper()
|
||||
var handler toolHandler
|
||||
entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute))
|
||||
for _, r := range allTools(pool, uuid.Nil, nil, entities, nil) {
|
||||
for _, r := range allTools(pool, uuid.Nil, nil, entities, nil, nil) {
|
||||
if r.tool.Name == name {
|
||||
handler = r.handler
|
||||
break
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -40,7 +40,7 @@ func (m *mockSecretBackend) Name() string { return "mock" }
|
||||
// findToolHandler locates a tool's handler from allTools by name.
|
||||
func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
t.Helper()
|
||||
for _, r := range allTools(nil, uuid.Nil, sec, nil, nil) {
|
||||
for _, r := range allTools(nil, uuid.Nil, sec, nil, nil, nil) {
|
||||
if r.tool.Name == name {
|
||||
return r.handler
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/dtoro/oikos/internal/actuator"
|
||||
"github.com/dtoro/oikos/internal/core/app"
|
||||
"github.com/dtoro/oikos/internal/core/domain"
|
||||
"github.com/dtoro/oikos/internal/core/ports"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||
@@ -52,8 +53,8 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
||||
|
||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) http.Handler {
|
||||
s := newServer(pool, agentID, sec, entities, relService)
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) http.Handler {
|
||||
s := newServer(pool, agentID, sec, entities, relService, execSvc)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if token != "" {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
@@ -68,11 +69,11 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
|
||||
// toolHandler is the function signature registered via AddTool.
|
||||
type toolHandler = mcp.ToolHandler
|
||||
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) *mcp.Server {
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) *mcp.Server {
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
for _, t := range allTools(pool, agentID, sec, entities, relService) {
|
||||
for _, t := range allTools(pool, agentID, sec, entities, relService, execSvc) {
|
||||
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
|
||||
}
|
||||
|
||||
@@ -602,449 +603,78 @@ func resolveProxmoxHostSlug(ctx context.Context, pool *db.Pool, entitySlug, host
|
||||
}
|
||||
|
||||
// classifyAndGate is the shared classify→execute-or-queue path for every
|
||||
// mutating command, used by both the general `run` tool and
|
||||
// request_execution's restart/systemctl/pct_exec actions. Those legacy
|
||||
// actions used to execute immediately over SSH with a hardcoded
|
||||
// risk_class='reversible_low' that was never actually evaluated against the
|
||||
// command — found live 2026-07-10 when a chat request to restart caddy (the
|
||||
// fleet's reverse proxy) executed instantly with no approval at all. Routing
|
||||
// every mutating path through the same classifier + approval-queue logic
|
||||
// closes that gap without special-casing each caller.
|
||||
// autoRun resolves a target, runs the command, and finalizes the execution
|
||||
// with full timing.
|
||||
//
|
||||
// The three auto-run windows (read-only, assent, destructive) each carried
|
||||
// their own copy of this logic, and none of them wrote duration_ms, started_at
|
||||
// or completed_at — so every auto-run execution landed in the ledger with no
|
||||
// timing at all, and the Ops "Duration" column was empty for exactly the
|
||||
// executions that run most often.
|
||||
func autoRun(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, command string) (string, error) {
|
||||
startedAt := time.Now()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`UPDATE executions SET status='running', started_at=$2 WHERE entity_id=$1`,
|
||||
id, startedAt); err != nil {
|
||||
slog.Error("mcp: mark execution running", "error", err, "execution_id", id)
|
||||
}
|
||||
|
||||
finalize := func(status string, result []byte) {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4,
|
||||
started_at=$5, completed_at=now()
|
||||
WHERE entity_id=$1`,
|
||||
id, status, result, int(time.Since(startedAt).Milliseconds()), startedAt); err != nil {
|
||||
slog.Error("mcp: finalize execution", "error", err, "execution_id", id)
|
||||
}
|
||||
}
|
||||
|
||||
host, user, wrap, err := resolveExecTarget(ctx, pool, targetSlug)
|
||||
if err != nil {
|
||||
finalize("failed", jsonErr("%s", err.Error()))
|
||||
return "", err
|
||||
}
|
||||
|
||||
var correlationID string
|
||||
if qerr := pool.QueryRow(ctx,
|
||||
`SELECT correlation_id FROM executions WHERE entity_id = $1`, id).Scan(&correlationID); qerr != nil {
|
||||
correlationID = ""
|
||||
}
|
||||
sink, flush := execlog.New(ctx, pool, id, correlationID)
|
||||
|
||||
out, err := sshExecStream(ctx, host, user, wrap(command), sink)
|
||||
flush()
|
||||
if err != nil {
|
||||
finalize("failed", jsonErr("%s: %s", err.Error(), out))
|
||||
return out, err
|
||||
}
|
||||
|
||||
finalize("completed", jsonOut(out))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// autoRunAsync starts a command in a goroutine, marking it running and returning
|
||||
// immediately. The caller gets an execution_id to poll with get_execution_status.
|
||||
// Used for commands containing sleep/wait/poll loops that would exceed the MCP
|
||||
// client timeout (120s) — the execution continues server-side.
|
||||
func autoRunAsync(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, command string) {
|
||||
startedAt := time.Now()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`UPDATE executions SET status='running', started_at=$2 WHERE entity_id=$1`,
|
||||
id, startedAt); err != nil {
|
||||
slog.Error("mcp: mark execution running (async)", "error", err, "execution_id", id)
|
||||
}
|
||||
|
||||
host, user, wrap, err := resolveExecTarget(ctx, pool, targetSlug)
|
||||
if err != nil {
|
||||
pool.Exec(ctx,
|
||||
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonErr("%s", err.Error()), int(time.Since(startedAt).Milliseconds()))
|
||||
slog.Error("mcp: async run resolve target", "error", err, "execution_id", id, "target", targetSlug)
|
||||
return
|
||||
}
|
||||
|
||||
var correlationID string
|
||||
if qerr := pool.QueryRow(ctx,
|
||||
`SELECT correlation_id FROM executions WHERE entity_id = $1`, id).Scan(&correlationID); qerr != nil {
|
||||
correlationID = ""
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("mcp: async run panic", "panic", r, "execution_id", id)
|
||||
pool.Exec(context.Background(),
|
||||
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonErr("panic: %v", r), int(time.Since(startedAt).Milliseconds()))
|
||||
}
|
||||
}()
|
||||
|
||||
sink, flush := execlog.New(context.Background(), pool, id, correlationID)
|
||||
out, execErr := sshExecStream(context.Background(), host, user, wrap(command), sink)
|
||||
flush()
|
||||
if execErr != nil {
|
||||
pool.Exec(context.Background(),
|
||||
`UPDATE executions SET status='failed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonErr("%s: %s", execErr.Error(), out), int(time.Since(startedAt).Milliseconds()))
|
||||
slog.Error("mcp: async run failed", "error", execErr, "execution_id", id, "output", out)
|
||||
} else {
|
||||
pool.Exec(context.Background(),
|
||||
`UPDATE executions SET status='completed', result=$2::jsonb, duration_ms=$3, completed_at=now() WHERE entity_id=$1`,
|
||||
id, jsonOut(out), int(time.Since(startedAt).Milliseconds()))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// isLongRunningCommand detects shell commands containing sleep, wait, or poll
|
||||
// loops that indicate the command will exceed the MCP client timeout (120s).
|
||||
// These commands should use autoRunAsync to avoid the client timing out while
|
||||
// the command continues server-side.
|
||||
func isLongRunningCommand(cmd string) bool {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
// sleep with duration — `sleep 30`, `sleep 1m`, etc.
|
||||
if sleepRe.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
// while/shell poll loops with sleep: `while ...; do ... sleep; done`
|
||||
if pollRe.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
// standalone wait command
|
||||
if waitRe.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
sleepRe = regexp.MustCompile(`\bsleep\s+\d`)
|
||||
pollRe = regexp.MustCompile(`\bwhile\b.*\bsleep\b`)
|
||||
waitRe = regexp.MustCompile(`\bwait\s+\d|[&;]\s*wait\b`)
|
||||
)
|
||||
|
||||
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
|
||||
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
||||
|
||||
// Transport-aware escalation: read-only commands on LXC targets that
|
||||
// touch config paths (/opt/, /etc/) escalate to config_mutation.
|
||||
// The classifier only scores the command text, not the transport layer
|
||||
// — SSH-ing into a container to read /opt/ is riskier than running
|
||||
// the same command locally on the Proxmox host via pct exec.
|
||||
// Caught live: "cat /etc/hostname" on lxc:dns queued as config_mutation
|
||||
// while "pct exec 107 -- cat /etc/hostname" on host:hubris auto-ran.
|
||||
//
|
||||
// EXEMPTION: tail/head/cat/less/journalctl on log files (*.log, */logs/*)
|
||||
// are always read-only — caught live 2026-08-15 session f6a7d4e:
|
||||
// "tail -3 /opt/seanime/data/logs/seanime.log" on lxc:seanime was gated.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
||||
actionCol := "run:" + string(runParams)
|
||||
|
||||
// P1 plan-first gate: every task must propose a plan before any `run`,
|
||||
// read-only or not. The only carve-out is a pure-DB Q&A that calls no
|
||||
// `run` at all (those never reach this code path). Without this gate the
|
||||
// SOUL.md "MANDATORY TASK FLOW" is unenforceable prose — weaker models
|
||||
// skip propose_plan and go straight to run, leaving the operator with
|
||||
// 23 individual approvals and no plan to approve (the original
|
||||
// anti-pattern the flow exists to prevent). Mirrors D.1's structural
|
||||
// refusal pattern in complete_task. sessionID == "" means a direct MCP
|
||||
// call with no nomos session (e.g. an external script) — gate is a
|
||||
// no-op there, since there's no session to hold a plan.
|
||||
if sessionID != "" && !sessionHasPlan(ctx, pool, sessionID) {
|
||||
return textResult("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.")
|
||||
}
|
||||
|
||||
// Target validation: host-only commands (qm, pct, pvesh, iptables) must
|
||||
// not be dispatched against lxc:/vm: targets — those aren't Proxmox hosts
|
||||
// and don't have these tools. Caught live 2026-08-04: the agent ran
|
||||
// `qm stop 100` against lxc:dns, wasting a turn.
|
||||
|
||||
// Command syntax validation: catch LLM-generated bash bugs before they
|
||||
// hit the shell. The model sometimes inserts literal \n between commands
|
||||
// or puts spaces inside flags — these always fail, so reject early.
|
||||
if syntaxErr := validateCommandSyntax(command); syntaxErr != "" {
|
||||
return textResult(syntaxErr)
|
||||
}
|
||||
|
||||
if cmdPrefix, hostOnly := hostOnlyCommand(command); hostOnly && !strings.HasPrefix(targetSlug, "host:") {
|
||||
hostSuggestion := resolveProxmoxHostSlug(ctx, pool, targetSlug, "")
|
||||
if hostSuggestion == "" {
|
||||
hostSuggestion = "host:hubris or host:strong"
|
||||
}
|
||||
return textResult(fmt.Sprintf("Cannot run %q on %s — %s is a Proxmox host command. Use target %s instead.",
|
||||
cmdPrefix, targetSlug, cmdPrefix, hostSuggestion))
|
||||
}
|
||||
|
||||
// systemctl and docker work on hosts and LXCs, but not VMs.
|
||||
if cmdPrefix, hostLxc := hostLxcCommand(command); hostLxc {
|
||||
if !strings.HasPrefix(targetSlug, "host:") && !strings.HasPrefix(targetSlug, "lxc:") {
|
||||
return textResult(fmt.Sprintf("Cannot run %q on %s — %s only works on host:* or lxc:* targets.",
|
||||
cmdPrefix, 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
|
||||
// for approval and never execute — the agent has no way to learn it's
|
||||
// stuck (spotted live 2026-08-05: vm:zimaos had qemu_guest_agent=not_running,
|
||||
// the run queued forever, and the agent fell back to unsafe raw SSH).
|
||||
if strings.HasPrefix(targetSlug, "vm:") {
|
||||
var rawAttrs []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT attributes FROM entities WHERE id = $1`, targetID).Scan(&rawAttrs); err == nil {
|
||||
var attrs map[string]any
|
||||
if json.Unmarshal(rawAttrs, &attrs) == nil {
|
||||
if qga, ok := attrs["qemu_guest_agent"]; ok {
|
||||
qgaStr, _ := qga.(string)
|
||||
if qgaStr == "not_running" || qgaStr == "" {
|
||||
return textResult(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\"}).",
|
||||
targetSlug, qgaStr, targetSlug))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: an identical pending command (same target, command, and
|
||||
// purpose) blocks a re-request — stops a tool-calling loop from queuing
|
||||
// the same approval repeatedly.
|
||||
var existingID string
|
||||
derr := pool.QueryRow(ctx, `
|
||||
SELECT e.id::text FROM entities e
|
||||
JOIN executions ex ON ex.entity_id = e.id
|
||||
WHERE e.type = 'execution' AND ex.target_entity_id = $1
|
||||
AND ex.action = $2 AND ex.status = 'pending_approval'
|
||||
ORDER BY e.created_at DESC LIMIT 1`,
|
||||
targetID, actionCol).Scan(&existingID)
|
||||
if derr == nil && existingID != "" {
|
||||
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID))
|
||||
}
|
||||
|
||||
// P5: if this is a config_mutation command, no assent window is active,
|
||||
// and there's already a pending_approval for this session, refuse —
|
||||
// don't queue a second approval. The operator should see ONE approval
|
||||
// (the plan), approve it (which opens the assent window), and then all
|
||||
// subsequent config_mutation commands auto-run. Without this gate, the
|
||||
// agent queues N individual approvals before the operator can respond,
|
||||
// flooding the chat with approval cards — confirmed in session 20757eb9
|
||||
// (WhatsApp bridge: two approvals for what should have been one plan).
|
||||
if riskClass == policy.RiskConfigMutation && sessionID != "" && !assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
var anyPending int
|
||||
pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM nomos_plan_executions pe
|
||||
JOIN executions ex ON ex.entity_id = pe.execution_id
|
||||
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
|
||||
sessionID).Scan(&anyPending)
|
||||
if anyPending > 0 {
|
||||
return textResult("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.")
|
||||
}
|
||||
}
|
||||
|
||||
id, _ := uuid.NewV7()
|
||||
// Correlate the execution to the chat session that asked for it. This was
|
||||
// a fresh random UUID per execution, which correlated nothing — every row
|
||||
// had a unique value, so the correlation_id column and the
|
||||
// ?correlation_id= filter could only ever match one execution.
|
||||
//
|
||||
// Using the session id makes the field mean what it says ("what did this
|
||||
// session do?") and is what lets the chat tail live output: execution
|
||||
// events carry correlation_id, so the UI can match them to the session on
|
||||
// screen without a lookup. Falls back to a random id when there is no
|
||||
// session to scope to, keeping the column non-empty.
|
||||
correlationID := sessionID
|
||||
if correlationID == "" || correlationID == "ephemeral" {
|
||||
correlationID = uuid.New().String()
|
||||
}
|
||||
execName := "run on " + targetSlug + " (" + id.String() + ")"
|
||||
execSlug := "exec:" + targetSlug + ":" + id.String()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
||||
id, execSlug, execName); err != nil {
|
||||
return textResult(fmt.Sprintf("error: failed to create execution: %v", err))
|
||||
}
|
||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
||||
id, targetID, actionCol, riskClass, correlationID, agentID)
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
||||
id, targetID)
|
||||
if sessionID != "" {
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||
FROM entities t WHERE t.slug = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||
id, "task:"+sessionID)
|
||||
// Link execution to session for auto-continuation (nomos_plan_executions
|
||||
// was always empty — executions were never traceable back to sessions).
|
||||
if sid, serr := uuid.Parse(sessionID); serr == nil {
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO nomos_plan_executions (execution_id, session_id)
|
||||
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, id, sid)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-classify: write the classification decision to the classifications
|
||||
// table (was always empty — 0 rows despite 1,884 executions). The route
|
||||
// matches the auto-run vs queue-for-approval decision below.
|
||||
classRoute := "escalate"
|
||||
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
|
||||
classRoute = "auto-act"
|
||||
} else if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
classRoute = "auto-act"
|
||||
} else if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
|
||||
classRoute = "auto-act"
|
||||
}
|
||||
classReason, _ := json.Marshal(map[string]string{
|
||||
"command": command, "purpose": purpose, "target": targetSlug, "declared_risk": declaredRisk,
|
||||
// mutating command, used by both the general `run` tool and docker_exec.
|
||||
// Since Phase 4 of the hexagonal refactor this is a thin rendering shell:
|
||||
// the decision pipeline lives in app.PolicyService, the recording +
|
||||
// dispatch in app.ExecutionService; this maps the result onto the
|
||||
// agent-facing text.
|
||||
func classifyAndGate(ctx context.Context, pool *db.Pool, execSvc *app.ExecutionService, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
|
||||
res := execSvc.Submit(ctx, app.ExecutionSubmitCmd{
|
||||
AgentID: domain.UUID(agentID.String()),
|
||||
TargetID: domain.UUID(targetID.String()),
|
||||
TargetSlug: targetSlug,
|
||||
Command: command,
|
||||
Purpose: purpose,
|
||||
DeclaredRisk: declaredRisk,
|
||||
SessionID: sessionID,
|
||||
Async: app.IsLongRunningCommand(command),
|
||||
SinkFactory: func(ctx context.Context, execID domain.UUID, correlationID string) (func(string, []byte), func()) {
|
||||
return execlog.New(ctx, pool, uuid.MustParse(string(execID)), correlationID)
|
||||
},
|
||||
})
|
||||
classID, _ := uuid.NewV7()
|
||||
pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'classification', $3, '{}')`,
|
||||
classID, "classification:"+classID.String(), "classification for "+execSlug)
|
||||
pool.Exec(ctx, `INSERT INTO classifications (entity_id, action, risk_class, route, reasoning, correlation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
classID, actionCol, riskClass, classRoute, classReason, correlationID)
|
||||
// Link classification to execution.
|
||||
pool.Exec(ctx, `UPDATE executions SET classification_id = $2 WHERE entity_id = $1`, id, classID)
|
||||
// Graph edge: classification —precedes→ execution (required by ontology).
|
||||
pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'precedes', '{"by":"nomos"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'precedes' AND valid_to IS NULL)`,
|
||||
classID, id)
|
||||
return renderSubmit(targetSlug, command, res)
|
||||
}
|
||||
|
||||
// Audit: record the execution creation with session_id for traceability.
|
||||
// Every run call, whether auto-run or queued-for-approval, gets an audit
|
||||
// entry so the agent's activity is traceable back to the originating session.
|
||||
var auditSessionID *uuid.UUID
|
||||
if sessionID != "" && sessionID != "ephemeral" {
|
||||
if sid, serr := uuid.Parse(sessionID); serr == nil {
|
||||
auditSessionID = &sid
|
||||
}
|
||||
}
|
||||
_ = observability.Audit(ctx, sqlcgen.New(pool), "agent", "nomos", "run",
|
||||
&id, "POST", "/mcp", correlationID, auditSessionID,
|
||||
map[string]any{"command": command, "target": targetSlug, "risk_class": riskClass, "purpose": purpose})
|
||||
// renderSubmit maps an ExecutionSubmitResult onto the agent-facing text,
|
||||
// preserving the exact pre-service message shapes.
|
||||
func renderSubmit(targetSlug, command string, res app.ExecutionSubmitResult) *mcp.CallToolResult {
|
||||
d := res.Decision
|
||||
switch d.Action {
|
||||
case app.DecisionRefuse:
|
||||
return textResult(d.Message)
|
||||
|
||||
// read_only and reversible_low both run unattended, as seeds/policy.yaml
|
||||
// and .agents/OIKOS.md declare ("reversible_low — restart, cache clear,
|
||||
// sync pull. Unattended + ledger.").
|
||||
//
|
||||
// reversible_low had no branch here, so it fell through to the gate. That
|
||||
// looked stricter but was actually perverse: computeCommandRisk never
|
||||
// returns reversible_low — the class can ONLY arise when the agent
|
||||
// declares it on a command the classifier already scored read_only
|
||||
// (ClassifyCommand keeps the higher of the two). So an agent that
|
||||
// honestly flagged "this restarts something" got gated, while the same
|
||||
// command with no declaration auto-ran. That penalised candor and gave
|
||||
// the agent a reason to stay quiet.
|
||||
//
|
||||
// Auto-running it is no more permissive than the read_only branch above,
|
||||
// because read_only is the only computed class it can accompany. An
|
||||
// agent still cannot talk a command DOWN: declaring reversible_low on
|
||||
// something computed as config_mutation keeps config_mutation.
|
||||
if riskClass == policy.RiskReadOnly || riskClass == policy.RiskReversibleLow {
|
||||
if isLongRunningCommand(command) {
|
||||
autoRunAsync(ctx, pool, id, targetSlug, command)
|
||||
return textResult(fmt.Sprintf("run on %s (%s, async): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, riskClass, id, id))
|
||||
case app.DecisionQueue:
|
||||
confirmNote := ""
|
||||
if d.RiskClass == policy.RiskDestructive {
|
||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||
}
|
||||
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
|
||||
if xerr != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s (%s, auto): %s", targetSlug, riskClass, out))
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
||||
targetSlug, d.RiskClass, res.ExecutionID, confirmNote))
|
||||
|
||||
// Assent window: if the operator recently approved a plan in this
|
||||
// agent's chat session, config_mutation commands auto-run without
|
||||
// re-approval. This is the "approve the plan, carry it out" path — the
|
||||
// operator approved the overall direction; individual config steps
|
||||
// within the window don't each need a separate yes. Destructive
|
||||
// commands never auto-run, regardless of window. (The old plan-window
|
||||
// path that opened on set_goal/propose_plan was removed — it opened
|
||||
// before approval, letting config_mutation auto-run with zero operator
|
||||
// consent. The assent window, opened only on operator approval, is the
|
||||
// sole gate for config_mutation auto-run.)
|
||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
if isLongRunningCommand(command) {
|
||||
autoRunAsync(ctx, pool, id, targetSlug, command)
|
||||
slog.Info("mcp: run async via assent window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (config_mutation, async via assent window): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, id, id))
|
||||
case app.DecisionAuto:
|
||||
label := d.RiskClass
|
||||
switch d.AutoViaWindow {
|
||||
case "assent":
|
||||
label = "config_mutation"
|
||||
case "destructive":
|
||||
label = "destructive"
|
||||
}
|
||||
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
|
||||
if xerr != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||
if res.AsyncStarted {
|
||||
via := ""
|
||||
switch d.AutoViaWindow {
|
||||
case "assent":
|
||||
via = ", async via assent window"
|
||||
case "destructive":
|
||||
via = ", async via confirmed-target window"
|
||||
default:
|
||||
via = ", async"
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s (%s%s): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, label, via, res.ExecutionID, res.ExecutionID))
|
||||
}
|
||||
slog.Info("mcp: run auto-executed via assent window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (config_mutation, auto via assent window): %s", targetSlug, out))
|
||||
if res.Err != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, res.Err, res.Output))
|
||||
}
|
||||
via := ", auto"
|
||||
switch d.AutoViaWindow {
|
||||
case "assent":
|
||||
via = ", auto via assent window"
|
||||
case "destructive":
|
||||
via = ", auto via confirmed-target window"
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s (%s%s): %s", targetSlug, label, via, res.Output))
|
||||
}
|
||||
|
||||
// Destructive window: a narrow, TARGET-scoped grant opened only after an
|
||||
// operator's explicit typed confirmation ("I confirm") on this same
|
||||
// target — never by loose assent. Exists for multi-step destructive
|
||||
// recovery (e.g. a failed destroy needing stop, then destroy) so the
|
||||
// operator isn't asked to re-type "I confirm" for every single command
|
||||
// against the thing they just confirmed.
|
||||
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
|
||||
if isLongRunningCommand(command) {
|
||||
autoRunAsync(ctx, pool, id, targetSlug, command)
|
||||
slog.Info("mcp: run async via destructive window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (destructive, async via confirmed-target window): started — execution %s. Poll with get_execution_status(%s) for result.",
|
||||
targetSlug, id, id))
|
||||
}
|
||||
out, xerr := autoRun(ctx, pool, id, targetSlug, command)
|
||||
if xerr != nil {
|
||||
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
|
||||
}
|
||||
slog.Info("mcp: run auto-executed via destructive window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (destructive, auto via confirmed-target window): %s", targetSlug, out))
|
||||
}
|
||||
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
|
||||
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
||||
markSessionAwaitingApproval(ctx, pool, sessionID)
|
||||
confirmNote := ""
|
||||
if riskClass == policy.RiskDestructive {
|
||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||
}
|
||||
return textResult(fmt.Sprintf("run on %s requires approval (risk: %s) — execution %s queued.%s Present the command and purpose to the operator and wait; do not re-request.",
|
||||
targetSlug, riskClass, id, confirmNote))
|
||||
return textResult(fmt.Sprintf("run on %s: unknown decision %q", targetSlug, d.Action))
|
||||
}
|
||||
|
||||
// autoApprove updates the approval + execution status in the DB to approved,
|
||||
@@ -1088,196 +718,6 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
|
||||
}
|
||||
}
|
||||
|
||||
// planWindowActive was removed 2026-07-15: it opened on set_goal and
|
||||
// propose_plan, letting config_mutation auto-run before operator approval.
|
||||
// The assent window (opened only on approval in agent.go) is the sole gate
|
||||
// for config_mutation auto-run now. See sessionHasPlan for the plan-existence
|
||||
// check used by the P1 plan-first gate.
|
||||
|
||||
// hostOnlyCommands maps command prefixes that are only valid on Proxmox host
|
||||
// targets (not LXCs or VMs). 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 the command can only run on
|
||||
// a host: target.
|
||||
func hostOnlyCommand(cmd string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
first := parts[0]
|
||||
// Check for shell wrappers: bash -c 'actual_cmd', sh -c 'actual_cmd'
|
||||
if (first == "bash" || first == "sh") && len(parts) >= 3 && parts[1] == "-c" {
|
||||
// The actual command is inside the -c argument; extract the first word.
|
||||
// This handles `bash -c 'qm stop 100'` but not deeply nested wrappers.
|
||||
actual := strings.Trim(strings.Join(parts[2:], " "), "'\"")
|
||||
if inner := strings.Fields(actual); len(inner) > 0 {
|
||||
first = inner[0]
|
||||
}
|
||||
}
|
||||
// Strip path: /usr/sbin/qm → qm
|
||||
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
|
||||
first = first[idx+1:]
|
||||
}
|
||||
return first, hostOnlyCommands[first]
|
||||
}
|
||||
|
||||
// hostLxcCommand checks whether the leading word of cmd is a command valid on
|
||||
// host:* and lxc:* targets but not vm:*. Returns the command word and true if
|
||||
// the command is restricted to host/lxc.
|
||||
func hostLxcCommand(cmd string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
first := parts[0]
|
||||
if idx := strings.LastIndexByte(first, '/'); idx >= 0 {
|
||||
first = first[idx+1:]
|
||||
}
|
||||
return first, hostLxcCommands[first]
|
||||
}
|
||||
|
||||
// isLogInspectionRead returns true if the command is a safe read-only operation
|
||||
// on a log file — tail, head, cat, less, or journalctl with a .log or /logs/ path.
|
||||
// Used by the transport-aware escalation check to avoid gating the most common
|
||||
// debugging action (e.g. "tail -3 /opt/seanime/data/logs/seanime.log" on lxc:seanime).
|
||||
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 "" if the command looks valid.
|
||||
func validateCommandSyntax(cmd string) string {
|
||||
// Reject literal \n (the LLM sometimes writes `echo "---" && \n curl ...`
|
||||
// — the \n is literal in the command string, not an actual newline).
|
||||
if strings.Contains(cmd, "\\n") {
|
||||
return fmt.Sprintf("Command contains literal '\\n' — use ';' or '&&' between commands, not a literal backslash-n. Command: %q", cmd)
|
||||
}
|
||||
|
||||
// Reject `&& \n` patterns (the LLM writes `cmd1 && \n cmd2` — the \n is
|
||||
// a literal newline that bash interprets as a command separator, but the
|
||||
// leading backslash makes it a syntax error).
|
||||
if andBackslashRe.MatchString(cmd) {
|
||||
return fmt.Sprintf("Command contains '&&' followed by a literal backslash-newline — remove the backslash or use ';' instead. Command: %q", cmd)
|
||||
}
|
||||
|
||||
// Reject `\` at end of command with no continuation (last line ends with
|
||||
// backslash but there's nothing after it).
|
||||
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)
|
||||
}
|
||||
|
||||
// Warn on common flag typos: `head - n`, `grep - i`, `tail - n`, etc.
|
||||
// These are space-between-flag-and-value errors the LLM produces.
|
||||
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`)
|
||||
var flagSpaceRe = regexp.MustCompile(`\b(head|tail|grep|sed|awk|sort|uniq|wc)\s+(-\w)\s+\w`)
|
||||
|
||||
// sessionHasPlan reports whether this nomos session has any plan step on
|
||||
// record that isn't `replaced`. Replaced steps (from session reopen via
|
||||
// store.reopenSession) don't count — the agent must propose fresh plan before
|
||||
// any `run`. Fails closed (returns true) when the query errors so a transient
|
||||
// DB issue doesn't block an otherwise-valid run.
|
||||
func sessionHasPlan(ctx context.Context, pool *db.Pool, sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return true // no session → no gate (direct MCP call from a script)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM session_plan_steps
|
||||
WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID).Scan(&count); err != nil {
|
||||
return true // fail open on DB error — don't block work over a flake
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// assentWindowActive checks whether the operator has recently approved a plan
|
||||
// in THIS TASK's chat session. The agent sets an
|
||||
// assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an
|
||||
// expiry timestamp when chat-assent grants a pending execution. While
|
||||
// active, config_mutation commands auto-run without re-approval — the
|
||||
// operator approved the overall plan, not each step. Scoped by session, not
|
||||
// just agent: with one agent:nomos entity serving every concurrent task, an
|
||||
// agent-only key would let approving Task A's plan silently auto-run
|
||||
// unapproved actions from a concurrently-running Task B. sessionID comes
|
||||
// from the `_session_id` nomos injects into every tool call's wire args
|
||||
// (never part of any tool's declared InputSchema, so the model never
|
||||
// supplies or sees it) — see cmd/nomos/agent.go's tool dispatch loop.
|
||||
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, sessionID string) bool {
|
||||
if agentID == uuid.Nil || sessionID == "" {
|
||||
return false // fail closed: no session to scope to means no window
|
||||
}
|
||||
var expiresStr string
|
||||
err := pool.QueryRow(ctx,
|
||||
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||
"assent_window.agent:"+agentID.String()+".session:"+sessionID).Scan(&expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().Before(expires)
|
||||
}
|
||||
|
||||
// destructiveWindowActive reports whether targetSlug has a live, explicitly-
|
||||
// confirmed destructive grant for this agent WITHIN THIS SESSION/TASK. Key
|
||||
// format ("destructive_window.agent:<id>.target:<slug>.session:<id>") must
|
||||
// match cmd/nomos/store.go's openDestructiveWindow — both processes
|
||||
// read/write the same autonomy_settings row. Scoped to one target AND one
|
||||
// session so a typed confirmation for destroying container A in task X can
|
||||
// never be read as authorizing anything against container A from a
|
||||
// different, concurrently-running task Y.
|
||||
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug, sessionID string) bool {
|
||||
if agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
|
||||
return false
|
||||
}
|
||||
var expiresStr string
|
||||
err := pool.QueryRow(ctx,
|
||||
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID).Scan(&expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().Before(expires)
|
||||
}
|
||||
|
||||
// knowledgeSlugRe strips a title down to a slug segment.
|
||||
var knowledgeSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
@@ -1401,80 +841,6 @@ func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*
|
||||
return textResult(fmt.Sprintf("Saved knowledge %q as %s%s. It's now searchable via search_knowledge and will surface in future sessions.", title, slug, linked)), nil
|
||||
}
|
||||
|
||||
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
|
||||
p := map[string]any{"action": action, "params": params, "execution_id": execID.String()}
|
||||
payload, _ := json.Marshal(p)
|
||||
// approvals.entity_id is PK + FK to entities(id). Reuse the execution's
|
||||
// entity (already inserted by request_execution) so the FK is satisfied —
|
||||
// a fresh UUID here had no matching entities row, so the INSERT silently
|
||||
// failed, orphaning the execution and never alerting the operator. One
|
||||
// execution maps to at most one approval, so the 1:1 identity holds.
|
||||
if err := sqlcgen.New(pool).InsertApproval(ctx, sqlcgen.InsertApprovalParams{
|
||||
EntityID: execID,
|
||||
SubjectEntityID: &targetID,
|
||||
Action: action,
|
||||
RiskClass: riskClass,
|
||||
Kind: "execution",
|
||||
Payload: payload,
|
||||
TokenHash: nil,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
slog.Error("createApproval: insert approval", "error", err, "execution", execID)
|
||||
return
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE executions SET approval_id = $1 WHERE entity_id = $1`, execID); err != nil {
|
||||
slog.Error("createApproval: link approval to execution", "error", err, "execution", execID)
|
||||
}
|
||||
|
||||
// Emit for SSE fan-out — the operator-facing moment: an agent-requested
|
||||
// gated action is now awaiting a decision.
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "",
|
||||
map[string]any{"action": action, "params": params, "risk_class": riskClass})
|
||||
}
|
||||
|
||||
// markSessionAwaitingApproval flips a session to awaiting_input the moment
|
||||
// one of its gated executions is queued for approval — mirrors what
|
||||
// askOperator does for session_questions (cmd/nomos/store.go's askOperator),
|
||||
// so a pending execution approval reads as "needs input" to both the
|
||||
// frontend's Overview board (which only checks agent_sessions.status) and
|
||||
// the idle-sweep safety net (staleGoalSessions, cmd/nomos/store.go, which
|
||||
// already excludes awaiting_input from its stale-task sweep). Before this, a
|
||||
// task blocked on a config_mutation/destructive approval just sat at
|
||||
// 'executing' — indistinguishable from a task still genuinely working — so
|
||||
// the idle sweep would eventually nudge it and then auto-close it with
|
||||
// outcome=partial while the approval was still sitting there undecided.
|
||||
// The httpapi package's DecideApproval flips the session back out once the
|
||||
// approval is approved/denied/revoked (internal/httpapi/approvals.go).
|
||||
//
|
||||
// No-op for sessionID=="" (a direct MCP call with no nomos session) or a
|
||||
// session that's already terminal/already awaiting_input — the status IN
|
||||
// guard makes this safe to call unconditionally from classifyAndGate.
|
||||
func markSessionAwaitingApproval(ctx context.Context, pool *db.Pool, sessionID string) {
|
||||
if sessionID == "" || sessionID == "ephemeral" {
|
||||
return
|
||||
}
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now()
|
||||
WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, sessionID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), "task.status", sessionTaskEntity(ctx, pool, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"})
|
||||
}
|
||||
|
||||
// sessionTaskEntity resolves a session's own task-entity id, for anchoring
|
||||
// events to the right node in the graph — mirrors cmd/nomos/store.go's
|
||||
// (unexported) taskEntityPtr; duplicated here since that's a different
|
||||
// package's private method.
|
||||
func sessionTaskEntity(ctx context.Context, pool *db.Pool, sessionID string) *uuid.UUID {
|
||||
var id uuid.UUID
|
||||
if err := pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
|
||||
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
|
||||
// P1.5). For each target slug, it runs a single read-only shell command
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestNewServerRegistersTools(t *testing.T) {
|
||||
}()
|
||||
// pool is only used inside tool handlers (invoked per-call), not at
|
||||
// registration time, so a nil pool is safe for this construction test.
|
||||
s := newServer(nil, uuid.Nil, nil, nil, nil)
|
||||
s := newServer(nil, uuid.Nil, nil, nil, nil, nil)
|
||||
if s == nil {
|
||||
t.Fatal("newServer returned nil")
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ type toolReg struct {
|
||||
handler toolHandler
|
||||
}
|
||||
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) []toolReg {
|
||||
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) []toolReg {
|
||||
return append(append(append(append(
|
||||
[]toolReg{},
|
||||
EntityTools(pool, agentID, sec, entities, relService)...),
|
||||
OpsTools(pool, agentID, sec)...),
|
||||
OpsTools(pool, agentID, sec, execSvc)...),
|
||||
KnowledgeTools(pool, agentID, sec)...),
|
||||
AnalysisTools(pool, agentID, sec)...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user