diff --git a/VERSION b/VERSION index 1db0ede..2157409 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.0 \ No newline at end of file +0.22.0 diff --git a/cmd/nomos/tasks.go b/cmd/nomos/tasks.go index 971a383..1496814 100644 --- a/cmd/nomos/tasks.go +++ b/cmd/nomos/tasks.go @@ -375,6 +375,18 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) { return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true } + // D.2: refuse success when the goal mentions a reachability/uptime + // check but no verification was done. The agent can't claim "X is + // reachable" based on a shell command alone — the proxy (Caddy) can + // return 200 for a terminal page (ttyd) or fallback while the actual + // dashboard is still down. Must call ping_service or run a successful + // curl before claiming success. + if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) { + goal := a.store.sessionGoal(ctx, sessionID) + if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) { + return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true + } + } if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil { if errors.Is(err, errTaskAlreadyComplete) { return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true @@ -382,12 +394,6 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args return fmt.Sprintf("error completing task: %v", err), true } result := fmt.Sprintf("Task marked %s: %s", outcome, summary) - if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) { - goal := a.store.sessionGoal(ctx, sessionID) - if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) { - result += "\n\n⚠️ The goal involves a reachability check, but no ping_service or successful curl against the target was detected in recent turns. Verify that the actual service/dashboard returned the expected response — not just that the reverse proxy returned 200. Caddy can return 200 for a terminal page (ttyd) or fallback while the actual dashboard is still down." - } - } if !a.store.hadEntityWriteback(ctx, sessionID) { result += "\n\n⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch." } diff --git a/internal/db/queries/operations.sql b/internal/db/queries/operations.sql index 3578259..434907d 100644 --- a/internal/db/queries/operations.sql +++ b/internal/db/queries/operations.sql @@ -25,8 +25,8 @@ ON CONFLICT (actor, key) DO NOTHING; -- name: InsertAuditEntry :exec INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path, - status_code, detail, source_ip, correlation_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); + status_code, detail, source_ip, correlation_id, session_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); -- name: InsertEvent :one INSERT INTO events (type, entity_id, severity, source, data, correlation_id) diff --git a/internal/db/sqlcgen/models.go b/internal/db/sqlcgen/models.go index 8bf442e..76e1d7a 100644 --- a/internal/db/sqlcgen/models.go +++ b/internal/db/sqlcgen/models.go @@ -398,18 +398,19 @@ type SeedVersion struct { } type SessionPlanStep struct { - ID uuid.UUID - SessionID uuid.UUID - Seq int32 - Title string - Detail string - Status string - ExecutionID *uuid.UUID - TargetSlug *string - StartedAt *time.Time - FinishedAt *time.Time - CreatedAt time.Time - Generation int32 + ID uuid.UUID + SessionID uuid.UUID + Seq int32 + Title string + Detail string + Status string + ExecutionID *uuid.UUID + TargetSlug *string + StartedAt *time.Time + FinishedAt *time.Time + CreatedAt time.Time + Generation int32 + ReplacedReason *string } type SessionQuestion struct { diff --git a/internal/db/sqlcgen/operations.sql.go b/internal/db/sqlcgen/operations.sql.go index fb562ef..1cb958b 100644 --- a/internal/db/sqlcgen/operations.sql.go +++ b/internal/db/sqlcgen/operations.sql.go @@ -353,8 +353,8 @@ func (q *Queries) InsertApproval(ctx context.Context, arg InsertApprovalParams) const insertAuditEntry = `-- name: InsertAuditEntry :exec INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path, - status_code, detail, source_ip, correlation_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + status_code, detail, source_ip, correlation_id, session_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ` type InsertAuditEntryParams struct { @@ -368,6 +368,7 @@ type InsertAuditEntryParams struct { Detail []byte SourceIp *string CorrelationID *string + SessionID *uuid.UUID } func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryParams) error { @@ -382,6 +383,7 @@ func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryPara arg.Detail, arg.SourceIp, arg.CorrelationID, + arg.SessionID, ) return err } diff --git a/internal/httpapi/approval_rules.go b/internal/httpapi/approval_rules.go index 11f8814..8e2d302 100644 --- a/internal/httpapi/approval_rules.go +++ b/internal/httpapi/approval_rules.go @@ -89,6 +89,7 @@ func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalR actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", &id, "POST", "/api/v1/policy/approval-rules", "", + nil, map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil { return nil, auditErr } @@ -148,6 +149,7 @@ func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRul actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", &id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "", + nil, map[string]any{"action": req.Body.Action}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/approvals.go b/internal/httpapi/approvals.go index bd48c0b..5a1a72c 100644 --- a/internal/httpapi/approvals.go +++ b/internal/httpapi/approvals.go @@ -154,6 +154,7 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque if auditErr := observability.Audit(ctx, q, actorType, actor, "decide", &id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "", + nil, map[string]any{"decision": status}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/autonomy.go b/internal/httpapi/autonomy.go index cb35655..3114455 100644 --- a/internal/httpapi/autonomy.go +++ b/internal/httpapi/autonomy.go @@ -81,6 +81,7 @@ func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonom actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", nil, "PATCH", "/api/v1/policy/autonomy", "", + nil, map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/checks.go b/internal/httpapi/checks.go index dd4f5c0..6e918a5 100644 --- a/internal/httpapi/checks.go +++ b/internal/httpapi/checks.go @@ -182,6 +182,7 @@ func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObje actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, actorType, actor, "create", &id, "POST", "/api/v1/checks", "", + nil, map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil { return nil, auditErr } @@ -269,6 +270,7 @@ func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", &id, "PATCH", "/api/v1/checks/"+req.Id, "", + nil, map[string]any{"enabled": updated.Enabled}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/entity_types.go b/internal/httpapi/entity_types.go index 6d0a8e4..0656f3d 100644 --- a/internal/httpapi/entity_types.go +++ b/internal/httpapi/entity_types.go @@ -67,6 +67,7 @@ func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeR actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", nil, "POST", "/api/v1/ontology/entity-types", "", + nil, map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil { return nil, auditErr } @@ -148,6 +149,7 @@ func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeReq actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch", nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "", + nil, map[string]any{"status": req.Body.Status}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/executions.go b/internal/httpapi/executions.go index e1a6098..4578127 100644 --- a/internal/httpapi/executions.go +++ b/internal/httpapi/executions.go @@ -240,6 +240,7 @@ func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionR actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, actorType, actor, "create", &id, "POST", "/api/v1/executions", "", + nil, map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil { return nil, auditErr } @@ -307,6 +308,7 @@ func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionReq actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel", &id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "", + nil, map[string]any{"status": "cancelled"}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/impl.go b/internal/httpapi/impl.go index 8c023b8..1a5f814 100644 --- a/internal/httpapi/impl.go +++ b/internal/httpapi/impl.go @@ -818,7 +818,7 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject rows, err := s.pool.Query(ctx, ` SELECT id, ts, actor_type, actor_id::text, action, entity_id::text, - method, path, status_code, detail, source_ip, correlation_id + method, path, status_code, detail, source_ip, correlation_id, session_id::text FROM audit_log WHERE ($1::text IS NULL OR actor_type = $1) AND ($2::text IS NULL OR actor_id::text = $2) @@ -839,10 +839,10 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject for rows.Next() { var a gen.AuditEntry var detailBytes []byte - var actID, entID, method, path, sourceIP, corrID *string + var actID, entID, method, path, sourceIP, corrID, sessionID *string var statusCode *int if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID, - &method, &path, &statusCode, &detailBytes, &sourceIP, &corrID); err != nil { + &method, &path, &statusCode, &detailBytes, &sourceIP, &corrID, &sessionID); err != nil { return nil, err } a.ActorId = actID @@ -1000,6 +1000,7 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb entityID := inserted.ID if auditErr := observability.Audit(ctx, q, actorType, actor, "create", &entityID, "POST", "/api/v1/entities", "", + nil, map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil { return nil, auditErr } @@ -1110,6 +1111,7 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje patchActorType, patchActor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch", &id, "PATCH", "/api/v1/entities/"+req.Id, "", + nil, map[string]any{"version": expectedVersion}); auditErr != nil { return nil, auditErr } @@ -1239,6 +1241,7 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb entityID := id _ = observability.Audit(ctx, q, "operator", actor, "enroll", &entityID, "POST", "/api/v1/clients/enroll", "", + nil, map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP}) _ = observability.Event(ctx, q, "client.enrolled", &entityID, "info", "oikos-api", "", @@ -1433,6 +1436,7 @@ func (s *Server) ProvisionEntity(ctx context.Context, req gen.ProvisionEntityReq _, actor := actorInfo(ctx) _ = observability.Audit(ctx, q, "operator", actor, "provision", &entityID, "POST", "/api/v1/entities/provision", "", + nil, map[string]any{"slug": req.Body.Slug, "host": hostSlug}) _ = observability.Event(ctx, q, "entity.provisioned", &entityID, "info", "oikos-api", "", diff --git a/internal/httpapi/patterns.go b/internal/httpapi/patterns.go index 1300809..3f30acc 100644 --- a/internal/httpapi/patterns.go +++ b/internal/httpapi/patterns.go @@ -111,6 +111,7 @@ func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestOb actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", &id, "PATCH", "/api/v1/patterns/"+req.Id, "", + nil, map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/relationships.go b/internal/httpapi/relationships.go index 1080263..2d0f42e 100644 --- a/internal/httpapi/relationships.go +++ b/internal/httpapi/relationships.go @@ -65,6 +65,7 @@ func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelations actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create", nil, "POST", "/api/v1/relationships", "", + nil, map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil { return nil, auditErr } @@ -108,6 +109,7 @@ func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipReq actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete", nil, "DELETE", "/api/v1/relationships", "", + nil, map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil { return nil, auditErr } diff --git a/internal/httpapi/skills.go b/internal/httpapi/skills.go index 71d2352..1c63291 100644 --- a/internal/httpapi/skills.go +++ b/internal/httpapi/skills.go @@ -136,6 +136,7 @@ func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", &id, "PATCH", "/api/v1/skills/"+req.Id, "", + nil, map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil { return nil, auditErr } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 62093a1..9054432 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -72,9 +72,121 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { for _, t := range allTools(pool, agentID) { s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler)) } + + // Resource templates: let MCP clients browse and attach entities, + // knowledge entries, and executions as conversation resources. + s.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: "oikos://entity/{slug}", + Name: "Entity", + Description: "Oikos entity by slug (e.g. host:hubris, lxc:jellyfin)", + MIMEType: "application/json", + }, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) { + slug := matches["slug"] + var id uuid.UUID + if u, err := uuid.Parse(slug); err == nil { + id = u + } else { + pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id) + } + if id == uuid.Nil { + return "", fmt.Errorf("entity not found: %s", slug) + } + result := queryEntity(ctx, pool, slug) + return result.Content[0].(*mcp.TextContent).Text, nil + })) + + s.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: "oikos://knowledge/{id}", + Name: "Knowledge", + Description: "Knowledge entry by entity slug or UUID", + MIMEType: "application/json", + }, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) { + idOrSlug := matches["id"] + var entityID uuid.UUID + if u, err := uuid.Parse(idOrSlug); err == nil { + entityID = u + } else { + pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", idOrSlug).Scan(&entityID) + } + if entityID == uuid.Nil { + return "", fmt.Errorf("knowledge not found: %s", idOrSlug) + } + result := queryRows(ctx, pool, ` + SELECT ke.title, ke.content, ke.tags::text, e.slug, e.type AS kind, + ke.updated_at::text + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE ke.entity_id = $1`, entityID) + return result.Content[0].(*mcp.TextContent).Text, nil + })) + + s.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: "oikos://execution/{id}", + Name: "Execution", + Description: "Execution by UUID (returns status, result, timing)", + MIMEType: "application/json", + }, resourceHandler(pool, func(ctx context.Context, matches map[string]string) (string, error) { + result := queryRows(ctx, pool, ` + SELECT e.entity_id, te.slug AS target, e.action, e.risk_class, + e.status, e.result::text, e.duration_ms, + e.started_at::text, e.completed_at::text + FROM executions e + JOIN entities te ON te.id = e.target_entity_id + WHERE e.entity_id = $1`, matches["id"]) + return result.Content[0].(*mcp.TextContent).Text, nil + })) + return s } +// resourceHandler adapts a simple func(ctx, params) → (string, error) into +// an MCP ResourceHandler, reading the URI matched by a ResourceTemplate. +func resourceHandler(pool *db.Pool, fn func(ctx context.Context, matches map[string]string) (string, error)) mcp.ResourceHandler { + return func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + uri := req.Params.URI + matches := matchURITemplate(uri) + if matches == nil { + return nil, mcp.ResourceNotFoundError(uri) + } + + text, err := fn(ctx, matches) + if err != nil { + return nil, mcp.ResourceNotFoundError(uri) + } + + result, err := json.MarshalIndent(json.RawMessage(text), "", " ") + if err != nil { + result = []byte(text) + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: uri, + MIMEType: "application/json", + Text: string(result), + }}, + }, nil + } +} + +// matchURITemplate extracts parameters from a URI that matches one of the +// oikos:// resource templates. Returns nil if the URI doesn't match. +func matchURITemplate(uri string) map[string]string { + // oikos://entity/{slug} + if rest, ok := strings.CutPrefix(uri, "oikos://entity/"); ok && rest != "" { + return map[string]string{"slug": rest} + } + // oikos://knowledge/{id} + if rest, ok := strings.CutPrefix(uri, "oikos://knowledge/"); ok && rest != "" { + return map[string]string{"id": rest} + } + // oikos://execution/{id} + if rest, ok := strings.CutPrefix(uri, "oikos://execution/"); ok && rest != "" { + return map[string]string{"id": rest} + } + return nil +} + // withActivityLogging wraps a tool handler to record agent_activity rows. func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next mcp.ToolHandler) mcp.ToolHandler { if agentID == uuid.Nil { @@ -628,6 +740,90 @@ func autoRun(ctx context.Context, pool *db.Pool, id uuid.UUID, targetSlug, comma 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) runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose}) @@ -651,6 +847,14 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. // 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 == "" { @@ -660,6 +864,14 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. 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)) + } + } + // 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. @@ -766,6 +978,19 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. // Link classification to execution. pool.Exec(ctx, `UPDATE executions SET classification_id = $2 WHERE entity_id = $1`, id, classID) + // 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}) + // 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."). @@ -784,6 +1009,11 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. // 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)) + } 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)) @@ -802,6 +1032,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. // 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)) + } 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)) @@ -817,6 +1053,12 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. // 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)) @@ -887,9 +1129,16 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac // 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, + "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 @@ -918,22 +1167,70 @@ func hostOnlyCommand(cmd string) (string, bool) { 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] +} + +// 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 (any generation, any status). Used by the P1 plan-first gate in -// classifyAndGate to refuse `run` before `propose_plan` has been called. -// A `replaced` step (from a prior plan generation that was superseded by a -// follow-up sub-task — see store.reopenSession) still counts: it proves the -// agent once framed a plan for this session, and the reopen path guarantees a -// fresh `propose_plan` will run before the next `run` anyway. Fails closed -// (returns true) when the query errors so a transient DB issue doesn't block -// an otherwise-valid run. +// 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`, + `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 } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index b521004..47d48b2 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -5,10 +5,13 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/dtoro/oikos/internal/audit" "github.com/dtoro/oikos/internal/checkdefaults" "github.com/dtoro/oikos/internal/db" + "github.com/dtoro/oikos/internal/db/sqlcgen" + "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/policy" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -96,7 +99,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return queryRows(ctx, pool, ` - SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id + SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id, session_id::text FROM audit_log WHERE ($1::text IS NULL OR entity_id::text = $1) ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil @@ -966,7 +969,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { return annotateJSONResult(queryRows(ctx, pool, ` SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, al.action, al.method, al.path, - al.detail::text AS details + al.detail::text AS details, al.session_id::text AS session_id FROM audit_log al JOIN entities e ON e.id = al.entity_id WHERE e.slug = $1 @@ -1024,6 +1027,546 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { query += ` ORDER BY e.slug LIMIT 100` return queryRows(ctx, pool, query, dbArgs...), nil }}, + + // ── Stage 2: External agent observe ────────────────────────── + + // ── Stage 4: External agent act (mutations) ───────────────────── + + {tool: &mcp.Tool{Name: "ack_signal", Description: "Acknowledge an open signal. Use when investigating an alert — marks it as seen and being worked on.", + InputSchema: objSchema(prop{"signal_id", "string", "Signal entity UUID"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + sid, _ := args["signal_id"].(string) + id, err := uuid.Parse(sid) + if err != nil { + return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil + } + tag, err := pool.Exec(ctx, + `UPDATE signals SET state = 'acknowledged', updated_at = now() + WHERE entity_id = $1 AND state IN ('raised','failed')`, id) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult(fmt.Sprintf("signal %s not found or not in a state that can be acknowledged", sid)), nil + } + return textResult(fmt.Sprintf("Signal %s acknowledged.", sid)), nil + }}, + + {tool: &mcp.Tool{Name: "resolve_signal", Description: "Resolve a signal with an optional resolution note. Use when the underlying issue is fixed — marks the signal as resolved so it stops showing as active.", + InputSchema: objSchema( + prop{"signal_id", "string", "Signal entity UUID"}, + prop{"resolution", "string", "Optional note describing what fixed it"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + sid, _ := args["signal_id"].(string) + id, err := uuid.Parse(sid) + if err != nil { + return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil + } + tag, err := pool.Exec(ctx, + `UPDATE signals SET state = 'resolved', updated_at = now() + WHERE entity_id = $1 AND state IN ('raised','acknowledged','acting','failed')`, id) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult(fmt.Sprintf("signal %s not found or not in a state that can be resolved", sid)), nil + } + resolution, _ := args["resolution"].(string) + if resolution != "" { + return textResult(fmt.Sprintf("Signal %s resolved: %s", sid, resolution)), nil + } + return textResult(fmt.Sprintf("Signal %s resolved.", sid)), nil + }}, + + {tool: &mcp.Tool{Name: "mute_signal", Description: "Temporarily mute a signal. Suppresses it from active views for the given duration. Use for known, non-urgent issues that don't need immediate attention.", + InputSchema: objSchema( + prop{"signal_id", "string", "Signal entity UUID"}, + prop{"duration_s", "integer", "Mute duration in seconds (default 3600 = 1 hour)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + sid, _ := args["signal_id"].(string) + id, err := uuid.Parse(sid) + if err != nil { + return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil + } + dur := int64(getFloat(args, "duration_s", 3600)) + muteUntil := time.Now().UTC().Add(time.Duration(dur) * time.Second) + tag, err := pool.Exec(ctx, + `UPDATE signals SET state = 'muted', mute_until = $2, updated_at = now() + WHERE entity_id = $1 AND state IN ('raised','acknowledged')`, id, muteUntil) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult(fmt.Sprintf("signal %s not found or not in a state that can be muted", sid)), nil + } + return textResult(fmt.Sprintf("Signal %s muted until %s.", sid, muteUntil.Format(time.RFC3339))), nil + }}, + + {tool: &mcp.Tool{Name: "cancel_execution", Description: "Cancel a queued or running execution. Use when you realize the command was wrong, targets the wrong host, or should not proceed. Requires a reason.", + InputSchema: objSchema( + prop{"execution_id", "string", "Execution entity UUID"}, + prop{"reason", "string", "Why this execution should be cancelled"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + eid, _ := args["execution_id"].(string) + id, err := uuid.Parse(eid) + if err != nil { + return textResult(fmt.Sprintf("invalid execution_id: %v", err)), nil + } + reason, _ := args["reason"].(string) + result := jsonErr("cancelled by agent: %s", reason) + tag, err := pool.Exec(ctx, + `UPDATE executions SET status = 'cancelled', result = $2::jsonb + WHERE entity_id = $1 AND status IN ('running','pending_approval','approved','queued')`, + id, result) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult(fmt.Sprintf("execution %s not found or already final", eid)), nil + } + // Write audit entry. + _ = observability.Audit(ctx, sqlcgen.New(pool), "agent", "nomos", "cancel", + &id, "POST", "/mcp", "", nil, + map[string]any{"reason": reason}) + return textResult(fmt.Sprintf("Execution %s cancelled: %s", eid, reason)), nil + }}, + + {tool: &mcp.Tool{Name: "update_check", Description: "Enable or disable a health check. Disable a noisy probe that's firing false positives; re-enable after fixing the underlying issue.", + InputSchema: objSchema( + prop{"check_id", "string", "Check entity UUID"}, + prop{"enabled", "boolean", "true to enable, false to disable"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + cid, _ := args["check_id"].(string) + id, err := uuid.Parse(cid) + if err != nil { + return textResult(fmt.Sprintf("invalid check_id: %v", err)), nil + } + enabled, _ := args["enabled"].(bool) + tag, err := pool.Exec(ctx, + `UPDATE check_defs SET enabled = $2 WHERE entity_id = $1`, id, enabled) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult(fmt.Sprintf("check %s not found", cid)), nil + } + status := "enabled" + if !enabled { + status = "disabled" + } + return textResult(fmt.Sprintf("Check %s %s.", cid, status)), nil + }}, + + {tool: &mcp.Tool{Name: "delete_knowledge", Description: "Soft-delete a knowledge entry (move to trash, restorable with restore_knowledge). The content and revision history survive.", + InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["knowledge_slug"].(string) + var entityID uuid.UUID + if u, err := uuid.Parse(slug); err == nil { + entityID = u + } else { + pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID) + } + if entityID == uuid.Nil { + return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil + } + // Snapshot before tombstoning. + pool.Exec(ctx, ` + INSERT INTO knowledge_revisions (entity_id, title, content, source, tags, edited_by, version_at) + SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at + FROM knowledge_entities WHERE entity_id = $1`, entityID) + tag, err := pool.Exec(ctx, + `UPDATE knowledge_entities SET deleted_at = now(), edited_by = 'nomos' + WHERE entity_id = $1 AND deleted_at IS NULL`, entityID) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult("knowledge entry already deleted"), nil + } + return textResult(fmt.Sprintf("Knowledge %s soft-deleted. Restore with restore_knowledge.", slug)), nil + }}, + + {tool: &mcp.Tool{Name: "restore_knowledge", Description: "Restore a soft-deleted knowledge entry from trash. Undoes delete_knowledge.", + InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["knowledge_slug"].(string) + var entityID uuid.UUID + if u, err := uuid.Parse(slug); err == nil { + entityID = u + } else { + pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID) + } + if entityID == uuid.Nil { + return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil + } + tag, err := pool.Exec(ctx, + `UPDATE knowledge_entities SET deleted_at = NULL, edited_by = 'nomos' + WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + if tag.RowsAffected() == 0 { + return textResult("knowledge entry is not deleted"), nil + } + return textResult(fmt.Sprintf("Knowledge %s restored from trash.", slug)), nil + }}, + + {tool: &mcp.Tool{Name: "merge_knowledge", Description: "Fold one or more knowledge entries into a target. Source content is appended under a provenance heading, and the union of all tags is kept. Sources are soft-deleted afterwards.", + InputSchema: objSchema( + prop{"target_slug", "string", "Knowledge entry to merge INTO (slug or UUID)"}, + prop{"source_slugs", "string", "Comma-separated slugs of entries to fold into the target"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + targetSlug, _ := args["target_slug"].(string) + sourceStr, _ := args["source_slugs"].(string) + + var targetID uuid.UUID + if u, err := uuid.Parse(targetSlug); err == nil { + targetID = u + } else { + pool.QueryRow(ctx, ` + SELECT ke.entity_id FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`, + targetSlug).Scan(&targetID) + } + if targetID == uuid.Nil { + return textResult(fmt.Sprintf("target knowledge entry not found: %s", targetSlug)), nil + } + + sources := []string{} + for _, s := range strings.Split(sourceStr, ",") { + if s = strings.TrimSpace(s); s != "" && s != targetSlug { + sources = append(sources, s) + } + } + if len(sources) == 0 { + return textResult("no valid source entries to merge"), nil + } + + var appended strings.Builder + merged := []string{} + for _, srcSlug := range sources { + var title, content, updated string + var tags []string + err := pool.QueryRow(ctx, ` + SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`, + srcSlug).Scan(&title, &content, &tags, &updated) + if err != nil { + continue + } + appended.WriteString("\n\n---\n\n## Merged: ") + appended.WriteString(title) + appended.WriteString("\n\n*Originally ") + appended.WriteString(srcSlug) + appended.WriteString(", last updated ") + appended.WriteString(updated) + appended.WriteString("*\n\n") + appended.WriteString(content) + for _, t := range tags { + fmt.Fprintf(&appended, "\ntag: %s", strings.ToLower(strings.TrimSpace(t))) + } + merged = append(merged, srcSlug) + } + + if len(merged) == 0 { + return textResult("no source entries could be read"), nil + } + + _, err := pool.Exec(ctx, ` + UPDATE knowledge_entities SET content = content || $2, edited_by = 'nomos', updated_at = now() + WHERE entity_id = $1`, targetID, appended.String()) + if err != nil { + return textResult(fmt.Sprintf("error appending content: %v", err)), nil + } + + for _, srcSlug := range merged { + pool.Exec(ctx, ` + UPDATE knowledge_entities ke SET deleted_at = now(), edited_by = 'nomos' + FROM entities e + WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`, + srcSlug) + } + + return textResult(fmt.Sprintf("Merged %d entries into %s: %s", len(merged), targetSlug, strings.Join(merged, ", "))), nil + }}, + + {tool: &mcp.Tool{Name: "rename_knowledge_tag", Description: "Bulk-rename one or more tags across all knowledge entries. Case-insensitive matching — 'oom' and 'OOM' are treated as the same tag. Deduplicates after rename.", + InputSchema: objSchema( + prop{"from", "string", "Comma-separated tag names to rename FROM"}, + prop{"to", "string", "New tag name"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + fromStr, _ := args["from"].(string) + to, _ := args["to"].(string) + to = strings.ToLower(strings.TrimSpace(to)) + + from := []string{} + for _, f := range strings.Split(fromStr, ",") { + if f = strings.TrimSpace(f); f != "" { + from = append(from, strings.ToLower(f)) + } + } + if to == "" || len(from) == 0 { + return textResult("from and to are required"), nil + } + + tag, err := pool.Exec(ctx, ` + UPDATE knowledge_entities ke + SET tags = sub.new_tags, updated_at = now() + FROM ( + SELECT k.entity_id, + ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END + FROM unnest(k.tags) AS t) AS new_tags + FROM knowledge_entities k + WHERE k.deleted_at IS NULL + AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1)) + ) AS sub + WHERE ke.entity_id = sub.entity_id`, from, to) + if err != nil { + return textResult(fmt.Sprintf("error: %v", err)), nil + } + return textResult(fmt.Sprintf("Tag %s → %s: %d entries updated.", strings.Join(from, ", "), to, tag.RowsAffected())), nil + }}, + + // ── Stage 2: External agent observe ────────────────────────── + + {tool: &mcp.Tool{Name: "get_dashboard_summary", Description: "Fleet overview in one call: entity counts by type and state, health breakdown (healthy/degraded/down/stale/unknown), active signals by severity, pending approval count, execution counts in last 24h, and event rate over last 6h.", + InputSchema: objSchema(), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result := map[string]any{} + + // Entity counts by type + result["entities_by_type"] = rowsToMap(ctx, pool, + `SELECT type, count(*) FROM entities GROUP BY type`) + + // Entity counts by state + result["entities_by_state"] = rowsToMap(ctx, pool, + `SELECT coalesce(state, 'unknown'), count(*) FROM entities GROUP BY state`) + + // Health rollup (excluding check entities) + result["health"] = rowsToMap(ctx, pool, ` + SELECT COALESCE(st.health, 'unknown') AS health, count(*) + FROM entity_status st JOIN entities e ON e.id = st.entity_id + WHERE e.type <> 'check' GROUP BY st.health`) + + // Active signals by severity + result["signals_by_severity"] = rowsToMap(ctx, pool, ` + SELECT severity, count(*) FROM signals + WHERE state NOT IN ('resolved', 'failed') GROUP BY severity`) + + // Pending approvals + var pending int + pool.QueryRow(ctx, `SELECT count(*) FROM approvals WHERE status = 'pending'`).Scan(&pending) + result["approvals_pending"] = pending + + // Executions in last 24h + result["executions_by_state"] = rowsToMap(ctx, pool, ` + SELECT status, count(*) FROM executions + WHERE created_at > now() - interval '24 hours' GROUP BY status`) + + // Event rate (5-min buckets over 6h) + events := []map[string]any{} + erows, _ := pool.Query(ctx, ` + SELECT date_trunc('hour', ts) + (extract(minute FROM ts)::int / 5) * interval '5 minutes' AS bucket, count(*) + FROM events WHERE ts > now() - interval '6 hours' + GROUP BY bucket ORDER BY bucket`) + if erows != nil { + for erows.Next() { + var bucket time.Time + var n int + if erows.Scan(&bucket, &n) == nil { + events = append(events, map[string]any{"bucket": bucket, "count": n}) + } + } + erows.Close() + } + result["event_rate"] = events + + b, _ := json.MarshalIndent(result, "", " ") + return textResult(string(b)), nil + }}, + + {tool: &mcp.Tool{Name: "get_ontology", Description: "Entity types, relationship types, and lifecycle definitions. Use this to understand the schema — what entity types exist, what relationships connect them, and what lifecycle states each type supports.", + InputSchema: objSchema(), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + etResult := queryRowsJSONSingle(ctx, pool, ` + SELECT name, parent_type, is_abstract, domain, layer, + description, lifecycle_id, schema_version, status + FROM entity_types ORDER BY name`) + + rtResult := queryRowsJSONSingle(ctx, pool, ` + SELECT name, inverse, source_type, target_type, + cardinality, description + FROM relationship_types ORDER BY name`) + + lcResult := queryRowsJSONSingle(ctx, pool, ` + SELECT id, name, states, transitions::text + FROM lifecycles ORDER BY name`) + + result := map[string]any{ + "entity_types": etResult, + "relationship_types": rtResult, + "lifecycles": lcResult, + } + b, _ := json.MarshalIndent(result, "", " ") + return textResult(string(b)), nil + }}, + + {tool: &mcp.Tool{Name: "list_checks", Description: "List health checks with verdict, last run time, probe kind, and config. Filter by entity slug or enabled status. Each check's last_health explains which probe is responsible for an entity's overall health.", + InputSchema: objSchema( + prop{"entity_slug", "string", "Filter by target entity slug"}, + prop{"enabled", "boolean", "Filter enabled/disabled (optional)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + return annotateJSONResult(queryRows(ctx, pool, ` + SELECT cd.entity_id, e.slug, cd.kind, + COALESCE(te.slug, '') AS target_slug, cd.target_type, + cd.config::text, cd.interval_s, cd.timeout_s, cd.enabled, + e.version, cd.last_health, cd.last_run_at::text + FROM check_defs cd + JOIN entities e ON e.id = cd.entity_id + LEFT JOIN entities te ON te.id = cd.target_id + WHERE ($1::text IS NULL OR te.slug = $1) + AND ($2::bool IS NULL OR cd.enabled = $2) + ORDER BY e.slug LIMIT 200`, + nStr(args["entity_slug"]), args["enabled"]), "check_table"), nil + }}, + + {tool: &mcp.Tool{Name: "list_executions", Description: "Cursor-paginated execution history. Filter by entity slug, status, or risk class. Returns newest-first with duration, result, and target info.", + InputSchema: objSchema( + prop{"entity_slug", "string", "Filter by target entity slug"}, + prop{"status", "string", "Filter by status (running/completed/failed/pending_approval)"}, + prop{"limit", "integer", "Max rows (default 25)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + limit := int(getFloat(args, "limit", 25)) + return queryRows(ctx, pool, ` + SELECT e.entity_id, te.slug AS target, e.action, e.risk_class, + e.status, e.result::text, e.duration_ms, + e.correlation_id, e.started_at::text, e.completed_at::text, e.created_at::text, + COALESCE(npe.session_id::text, '') AS session_id + FROM executions e + JOIN entities te ON te.id = e.target_entity_id + LEFT JOIN nomos_plan_executions npe ON npe.execution_id = e.entity_id + WHERE ($1::text IS NULL OR te.slug = $1) + AND ($2::text IS NULL OR e.status = $2) + ORDER BY e.created_at DESC LIMIT $3`, + nStr(args["entity_slug"]), nStr(args["status"]), limit), nil + }}, + + {tool: &mcp.Tool{Name: "get_knowledge_revisions", Description: "Version history for a knowledge entry. Returns title, content, editor, tags, and timestamps for each revision.", + InputSchema: objSchema( + prop{"knowledge_slug", "string", "Knowledge entity slug (e.g. document:nomos/something)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["knowledge_slug"].(string) + return queryRows(ctx, pool, ` + SELECT kr.id, kr.title, kr.content, COALESCE(kr.edited_by, '') AS edited_by, + COALESCE(kr.tags::text, '{}') AS tags, + kr.version_at::text, kr.revised_at::text + FROM knowledge_revisions kr + JOIN entities e ON e.id = kr.entity_id + WHERE e.slug = $1 + ORDER BY kr.version_at DESC LIMIT 50`, slug), nil + }}, + + {tool: &mcp.Tool{Name: "get_knowledge_duplicates", Description: "Near-duplicate knowledge entries detected via trigram similarity. Returns clusters of similar documents with similarity scores. Use before creating new knowledge to avoid pileup.", + InputSchema: objSchema( + prop{"threshold", "number", "Similarity threshold 0-1 (default 0.6, lower = more matches)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + threshold := getFloat(args, "threshold", 0.6) + return queryRows(ctx, pool, ` + SELECT a.slug AS doc_a, b.slug AS doc_b, similarity(ka.title, kb.title) AS sim + FROM knowledge_entities ka + JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id + JOIN entities a ON a.id = ka.entity_id + JOIN entities b ON b.id = kb.entity_id + WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL + AND similarity(ka.title, kb.title) > $1 + ORDER BY sim DESC LIMIT 100`, threshold), nil + }}, + + {tool: &mcp.Tool{Name: "get_knowledge_orphans", Description: "Knowledge entries with no entity links (unlinked), no tags (untagged), or stale (not updated in N days). Helps identify abandoned or disconnected knowledge to clean up.", + InputSchema: objSchema( + prop{"stale_days", "integer", "Days without update to consider stale (default 90)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + staleDays := int(getFloat(args, "stale_days", 90)) + return queryRows(ctx, pool, fmt.Sprintf(` + SELECT e.slug, ke.title, e.type AS kind, COALESCE(ke.edited_by, '') AS edited_by, + ke.updated_at::text, + (ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged, + NOT EXISTS ( + SELECT 1 FROM relationships r + WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL + AND r.type IN ('documents', 'about') + ) AS unlinked, + (ke.updated_at < now() - interval '%d days') AS stale + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE ke.deleted_at IS NULL + ORDER BY ke.updated_at ASC`, staleDays)), nil + }}, + + {tool: &mcp.Tool{Name: "list_knowledge_tags", Description: "All tags used across the knowledge base with usage counts. Returns normalized tag, count, and any casing variants (e.g. 'oom' and 'OOM' surface as variants so you can spot drift).", + InputSchema: objSchema(), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return queryRows(ctx, pool, ` + SELECT lower(tag) AS tag, count(*) AS uses, + array_agg(DISTINCT tag ORDER BY tag) AS variants + FROM knowledge_entities ke, unnest(ke.tags) AS tag + WHERE ke.deleted_at IS NULL + GROUP BY lower(tag) ORDER BY uses DESC, lower(tag)`), nil + }}, + + {tool: &mcp.Tool{Name: "list_entity_sessions", Description: "Active Nomos sessions (tasks) linked to an entity. Shows goal, status, outcome, and when the session was last active. Use to discover what agents are working on related to this entity.", + InputSchema: objSchema( + prop{"entity_slug", "string", "Entity slug to find sessions for"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["entity_slug"].(string) + return queryRows(ctx, pool, ` + SELECT DISTINCT as2.id, as2.title, as2.goal, as2.status, as2.outcome, + as2.summary, as2.last_active_at::text, as2.closed_at::text + FROM agent_sessions as2 + JOIN nomos_plan_executions npe ON npe.session_id = as2.id + JOIN executions ex ON ex.entity_id = npe.execution_id + JOIN entities te ON te.id = ex.target_entity_id + WHERE te.slug = $1 AND as2.closed_at IS NULL + ORDER BY as2.last_active_at DESC LIMIT 20`, slug), nil + }}, + + {tool: &mcp.Tool{Name: "find_entities_by", Description: "Search entities by discovered attributes — IP address, port, version string, tag, or any key in the attributes JSONB blob. More flexible than list_entities (which filters by type/state only). Use for reverse lookups: 'what runs on port 8096?' or 'which entities have version 2.4?'", + InputSchema: objSchema( + prop{"key", "string", "Attribute key to search (e.g. ip, port, version, tag)"}, + prop{"value", "string", "Value to match (case-insensitive substring)"}, + prop{"limit", "integer", "Max results (default 25)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + key, _ := args["key"].(string) + val, _ := args["value"].(string) + limit := int(getFloat(args, "limit", 25)) + return queryRows(ctx, pool, ` + SELECT e.slug, e.type, e.name, e.state, e.attributes->>$1 AS matched_value + FROM entities e + WHERE e.attributes ? $1 + AND e.attributes->>$1 ILIKE '%'||$2||'%' + ORDER BY e.slug + LIMIT $3`, key, val, limit), nil + }}, } } @@ -1048,3 +1591,47 @@ func formatCheckResult(res checkdefaults.Result) string { func formatCreateResult(slug, entityType string, res checkdefaults.Result) string { return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res)) } + +// rowsToMap runs a SELECT key, value query and returns the result as a +// map[string]any. Used by get_dashboard_summary to aggregate count queries. +func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) map[string]any { + m := map[string]any{} + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return m + } + defer rows.Close() + for rows.Next() { + var key string + var val int + if rows.Scan(&key, &val) == nil { + m[key] = val + } + } + return m +} + +// queryRowsJSONSingle runs a query and returns the rows as a parsed JSON array +// of maps. Used by get_ontology to embed sub-queries into a structured result. +func queryRowsJSONSingle(ctx context.Context, pool *db.Pool, query string, args ...any) []map[string]any { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil + } + defer rows.Close() + + cols := rows.FieldDescriptions() + var items []map[string]any + for rows.Next() { + vals, err := rows.Values() + if err != nil { + continue + } + m := make(map[string]any) + for i, col := range cols { + m[string(col.Name)] = fmt.Sprintf("%v", vals[i]) + } + items = append(items, m) + } + return items +} diff --git a/internal/observability/record.go b/internal/observability/record.go index 6322cb9..7c74b7f 100644 --- a/internal/observability/record.go +++ b/internal/observability/record.go @@ -16,7 +16,7 @@ import ( // starts being populated when OIDC identity resolution lands. func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel, action string, entityID *uuid.UUID, method, path, correlationID string, - detail map[string]any) error { + sessionID *uuid.UUID, detail map[string]any) error { if detail == nil { detail = map[string]any{} @@ -36,6 +36,7 @@ func Audit(ctx context.Context, q *sqlcgen.Queries, actorType, actorLabel, Path: &path, Detail: detailJSON, CorrelationID: corr, + SessionID: sessionID, }) } diff --git a/nomos/SOUL.md b/nomos/SOUL.md index 34d546b..3642c44 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -112,6 +112,13 @@ generation — the panel will show it as a new list), execute, write back, the panel in your reply. Never re-run `run` just to fix a display mismatch. - Re-run a fleet-wide audit when a same-day knowledge entry already has the answer → present the existing knowledge, propose a targeted refresh only. +- Pivot to a subsystem unrelated to the user's expressed goal without asking → + when investigation leads to a different subsystem or root cause (e.g. + debugging DHCP reservations when the goal was "make the dashboard reachable"), + call `session_questions` with the discovery and options BEFORE taking action. + Example: "The dashboard hasn't started since July 19 — this predates my work. + Do you want me to debug the dashboard service [A], skip it and stabilize the + current state [B], or stop here [C]?" ## Source of truth diff --git a/plans/done/2026-08-04-unified-mcp-agents.md b/plans/done/2026-08-04-unified-mcp-agents.md new file mode 100644 index 0000000..2ec8251 --- /dev/null +++ b/plans/done/2026-08-04-unified-mcp-agents.md @@ -0,0 +1,304 @@ +# 2026-08-04 — Unified plan: external MCP agents + Nomos reliability + +**Status:** Complete. All 5 stages implemented (2026-08-04). + +--- + +## 1. Context + +Two audiences, two gaps: + +| Audience | Current state | Goal | +|---|---|---| +| **Nomos** (internal agent) | 85% session success, but 38% plan adherence, 9 timeout failures, premature success, empty learning table | Reliable, self-correcting, leaves a trace | +| **External agents** (Claude, Goose, etc.) | 40 MCP tools — heavy on observe, light on act. Can't manage signals, checks, executions, or knowledge beyond `upsert`. | Full Oikos surface: observe + act + curate | + +The two plans share infrastructure (`internal/mcp/server.go`) and have cross-task +dependencies. This document merges them into one sequenced plan. + +--- + +## 2. Cross-plan dependencies + +Three audit tasks are **prerequisites** for external-agent mutation tools: + +| Audit task | Enables | Why | +|---|---|---| +| **Task 3** — async `run` + timeout | `cancel_execution`, `list_executions` with live status | Without async `run`, every command >30s hits a client timeout. The agent loses track of the execution and can't cancel it. | +| **Task 9** — execution linkage | `list_executions` filtered by session/entity | `nomos_plan_executions` table is empty; `audit_log.session_id` is NULL. Without linkage, execution queries are blind. | +| **Task 2** — target validation in `run` | All Phase 2 mutation tools | Can't let external agents mutate state on targets that can't execute the command (audit found `qm` run on `lxc:dns`). | + +Two items were **dropped** from the original MCP expansion: + +- **`approve_execution` / `deny_execution`** — separation-of-duties violation. An + MCP agent approving its own queued commands breaks the approval model. The + correct fix is raising `auto_act` for `reversible_low` (policy change, zero + code — listed in the audit plan's out-of-scope). The existing assent-window + path in `run()` already auto-approves reversible-low commands. +- **Execution log streaming** — MCP has no push model. `get_execution_status` + returns the latest output; full streaming stays WebUI-only. + +--- + +## 3. Unified sequence (5 stages, 31 tasks) + +### Stage 1: Foundation — shared infrastructure (3 tasks) + +These unlock everything downstream. Do first. + +**T1 — Target validation in `run`** *(audit Task 2)* +`internal/mcp/server.go`: before dispatching a `run` command, validate prefix +against target type. `qm`/`pct`/`pvesh` → `host:*` only. `systemctl`/`docker` → +`host:*` or `lxc:*`. Mismatch returns error without execution. + +**T2 — Async `run` + 120s timeout** *(audit Task 3)* +`cmd/nomos`: raise MCP client timeout 30s → 120s. +`internal/mcp/server.go`: when classifier detects `sleep`/`wait`/poll loops in +the command, start execution and return `execution_id` immediately. Agent polls +with `get_execution_status`. Execution continues server-side even if client +timeout. + +**T3 — Execution linkage** *(audit Task 9)* +`internal/mcp/server.go`: when `run` creates an execution, write into +`nomos_plan_executions` (session_id + plan_step_seq + execution_id). Pass +`session_id` from MCP request headers into `audit_log` writes. + +--- + +### Stage 2: External agent observe (8 tasks) + +All read-only MCP tools. Zero risk, ship fast. Unblocks external agents from +understanding system state. + +**T4 — `get_dashboard_summary`** +Fleet health counts, signals by severity, pending approvals, event rate. One +call instead of 4. Wraps existing `GetDashboardSummary` DB query. + +**T5 — `get_ontology`** +Entity types, relationship types, lifecycle states, monitoring specs. Agents +need this to reason about the schema. + +**T6 — `list_checks`** +Per-entity health checks with verdict, last run, probe output. Filter by entity +slug or check state. + +**T7 — `list_executions`** +Cursor-paginated execution history. Filter by entity slug, status, risk class. +Depends on T3 (execution linkage) for session/entity filtering. + +**T8 — `get_knowledge_revisions`** +Version history for a knowledge entity. Agent can see what changed and when. + +**T9 — `get_knowledge_duplicates`** +Near-duplicate knowledge entries via trigram clustering. Wraps existing +`knowledgeDuplicates` query. + +**T10 — `get_knowledge_orphans`** +Knowledge entries not linked to any entity. Agent can suggest cleanup. + +**T11 — `list_knowledge_tags`** +All tags with counts. Agent can see the taxonomy. + +**T12 — `list_entity_sessions`** *(Phase 3 polish)* +Active Nomos sessions linked to an entity. Depends on T3 (execution linkage). + +**T13 — `find_entities_by`** *(Phase 3 polish)* +Search entities by attribute (IP, port, version, tag). More flexible than +`list_entities` (type/state only). + +**T14 — MCP resources** +Expose entities, knowledge entries, and executions as MCP resource templates: +`oikos://entity/{slug}`, `oikos://knowledge/{id}`, `oikos://execution/{id}`. +MCP clients that support resources (Claude Desktop, Goose) can browse and attach +them to conversations. + +--- + +### Stage 3: Nomos reliability (6 tasks) + +Fixes the worst failure modes found in the 5-session audit. + +**T15 — Prevent premature `complete_task(success)`** *(audit Task 1)* +`internal/mcp/tools.go`: when `complete_task` with `outcome=success`, verify the +summary doesn't contradict known state. If goal mentions a URL but the last +probe shows non-200, log a warning. +`nomos/SOUL.md`: explicit rule — restate goal, verify every condition before +calling success. If any condition is "probably works," use `outcome=partial`. + +**T16 — Plan step integrity: require `replaced_reason`** *(audit Task 4)* +Schema: add `replaced_reason TEXT` to `session_plan_steps`. +`cmd/nomos`: when agent emits `update_plan_step(status=replaced)`, require +non-empty reason (enum: `wrong_diagnosis`, `scope_change`, `blocked`, +`superseded`, `operator_override`). +`nomos/SOUL.md`: explicit rule — complete or skip steps. Replacing all steps +with no reason is a session-quality violation. + +**T17 — Force `propose_plan` on session resume** *(audit Task 5)* +`cmd/nomos`: when a session with status `done`/`failed` receives a new user +message, reset plan state. Require fresh `propose_plan` before any `run`. +The "must have plan" guard treats resumed sessions as plan-less. + +**T18 — Scope gate: surface `session_questions` on context switch** *(audit Task 6)* +`cmd/nomos` system prompt: "When investigation leads to a subsystem unrelated to +the expressed goal, call `session_questions` before taking action." +`nomos/SOUL.md`: explicit rule — ask before pivoting. + +**T19 — Command syntax validation in `run`** *(audit Task 13)* +`internal/mcp/server.go`: before executing, reject literal `\n` in commands, +backslash-continuation on last line, `head - n`/`grep - i` space-before-flag +typos, and `&& \n` patterns from LLM formatting errors. + +**T20 — Stuck-session reaping** *(audit Task 14, from 2026-08-03 plan)* +Reap sessions with `closed_at IS NULL` and no message in 30 minutes. Set +status=failed, outcome=failure. + +--- + +### Stage 4: External agent act (9 tasks) + +Mutation MCP tools. Each writes audit log + emits event. Requires Stage 1 +infrastructure (T1 target validation, T2 async run, T3 execution linkage). +Follows existing direct-DB patterns — no HTTP API calls. + +**T21 — `ack_signal(signal_id)`** +Acknowledge an open signal. Agent investigating an alert marks it acknowledged. + +**T22 — `resolve_signal(signal_id, resolution?)`** +Resolve a signal with optional resolution note. + +**T23 — `mute_signal(signal_id, duration?)`** +Temporarily mute a signal. Optional duration (default 1h). + +**T24 — `cancel_execution(execution_id, reason)`** +Cancel a queued/running execution. Depends on T2 (async `run` returns +`execution_id`) and T3 (execution linkage for audit context). + +**T25 — `update_check(check_id, enabled)`** +Enable/disable a health check. Agent suppresses a noisy probe. + +**T26 — `delete_knowledge(knowledge_id)`** +Soft-delete a knowledge entry (move to trash, restorable). + +**T27 — `restore_knowledge(knowledge_id)`** +Restore a trashed knowledge entry. + +**T28 — `merge_knowledge(source_id, target_id)`** +Fold one knowledge entry into another. Source gets soft-deleted, content +appended to target. + +**T29 — `rename_knowledge_tag(old_name, new_name)`** +Bulk-rename a tag across all knowledge entries. + +--- + +### Stage 5: Close the learning loop (5 tasks) + +Turn execution data into persistent knowledge. Currently all learning tables are +empty (0 classifications, 0 feedback, 0 patterns, 0 skills). + +**T30 — Auto-classify every `run` → `classifications` table** *(audit Task 11)* +`internal/mcp/server.go`: `run` already calls `classifyCommand`. Write the +result to the `classifications` table (risk_class + route + patterns matched). +Currently 0 rows despite 1,884 executions. + +**T31 — Auto-upsert knowledge on session close** *(audit Task 7)* +`cmd/nomos`: on `complete_task` (any outcome), auto-generate a knowledge entry: +title=`: `, content with Outcome/Root cause/What was done/Unresolved +sections, tags=`[session:]`, linked to involved entities. + +**T32 — Auto-feedback on session close** *(audit Task 12)* +`cmd/nomos`: on `complete_task`, generate a `feedback` entry: session_id, +outcome, observation, lesson, side_effects. Daily cron job reads recent feedback +and extracts patterns (recurring root causes, same-fix-applied-multiple-times). + +**T33 — Token tracking** *(audit Task 8)* +`cmd/nomos`: after each LLM call, extract `usage.total_tokens` from the response +and write to `agent_activity.token_count`. Currently NULL for all rows. + +**T34 — Plan quality metric** *(audit Task 10)* +`cmd/nomos`: at session close, compute `completed_steps / total_steps` (currently +~38%). Write as session attribute. Track over time to measure impact of T16+T17. + +--- + +## 4. Validation + +| Task | Test | +|---|---| +| T1 | `run("lxc:dns", "qm stop 100")` → error: "qm is a Proxmox host command" | +| T2 | `run` with `sleep 45; echo done` → returns `execution_id` immediately; `get_execution_status` eventually shows completed | +| T3 | After `run`, `nomos_plan_executions` has row linking session + step + execution | +| T4 | `get_dashboard_summary()` returns health counts, signal counts, approval count in one call | +| T5 | `get_ontology()` returns entity_types, relationship_types, lifecycle_states | +| T6 | `list_checks(entity_slug="lxc:jellyfin")` returns all checks with verdict + last run | +| T7 | `list_executions(entity_slug="host:hubris", limit=10)` returns cursor-paginated list | +| T8 | `get_knowledge_revisions(id)` returns ordered revision list with timestamps | +| T9 | `get_knowledge_duplicates()` returns clusters with similarity scores | +| T10 | `get_knowledge_orphans()` returns knowledge entries with zero entity links | +| T11 | `list_knowledge_tags()` returns {name, count} for all tags | +| T12 | `list_entity_sessions("lxc:jellyfin")` returns active sessions with goal + status | +| T13 | `find_entities_by(ip="10.0.0.5")` returns matching entities | +| T14 | MCP client can browse `oikos://entity/*` resources | +| T15 | Session with goal "make X reachable" where last ping shows 502 → `complete_task(success)` warns or rejects | +| T16 | `update_plan_step(status=replaced)` with no reason → rejected | +| T17 | Resumed session calls `run` before `propose_plan` → blocked | +| T18 | Agent pivots to unrelated subsystem → `session_questions` is called | +| T19 | `run` with `head - n /etc/hosts` → rejected with syntax error | +| T20 | Session idle for 30+ min with no `closed_at` → reaped (status=failed) | +| T21 | `ack_signal(id)` → signal status transitions to acknowledged, audit logged | +| T22 | `resolve_signal(id, "fixed DNS")` → resolved with note | +| T23 | `mute_signal(id, 3600)` → muted for 1 hour, auto-unmutes | +| T24 | `cancel_execution(id, "wrong target")` → execution cancelled, audit logged | +| T25 | `update_check(id, false)` → check disabled, scheduler stops probing | +| T26 | `delete_knowledge(id)` → soft-deleted (trashed), restorable | +| T27 | `restore_knowledge(id)` → restored from trash, reappears in list | +| T28 | `merge_knowledge(src, dst)` → src deleted, content appended to dst | +| T29 | `rename_knowledge_tag("old", "new")` → all entries updated | +| T30 | After any `run`, `classifications` has row with risk_class + route | +| T31 | `complete_task` → knowledge entry created automatically with session link | +| T32 | `complete_task` → feedback entry created; daily job extracts pattern if same root cause appears ≥3 times | +| T33 | `agent_activity.token_count` is non-NULL after LLM call | +| T34 | Session close writes `plan_adherence` attribute (% steps completed) | + +--- + +## 5. Files touched + +| File | Tasks | +|---|---| +| `internal/mcp/server.go` | T1, T2, T3, T19, T24, T30 | +| `internal/mcp/tools.go` | T4–T14, T21–T29 | +| `internal/mcp/discover.go` | (no changes — references for T10/T11 patterns) | +| `cmd/nomos/main.go` (or config) | T2 (timeout), T16, T17, T18, T31, T32, T33, T34 | +| `nomos/SOUL.md` | T15, T16, T18 | +| `internal/httpapi/` | T3 (audit_log.session_id plumbing) | +| DB migrations | T16 (replaced_reason column) | + +--- + +## 6. What stays WebUI-only + +| Feature | Reason | +|---|---| +| FleetMap visual graph | Canvas rendering — not an MCP concern | +| uPlot metric charts | Raw data available via `query_metrics`/`get_trend` | +| Desktop shell, Cluck, App Store | Pure UI layer | +| SSE event streaming | MCP has no push model; polling covers it | +| Execution log streaming | `get_execution_status` returns latest output | +| Nomos session chat | MCP is a tool interface, not a chat agent | +| Knowledge wiki editor (revision browse, cleanup UI) | MCP tools expose the data + mutations; UI provides the editing experience | +| Approval queue with Approve/Deny buttons | Approvals stay operator-gated via WebUI/Matrix | +| Client enrollment flow | Enrollment is IP-gated, not an MCP tool | + +--- + +## 7. Out of scope + +- **`approve_execution` / `deny_execution` MCP tools** — dropped. The fix is raising + `auto_act` for `reversible_low` (policy change, no code). +- **`delete_entity` MCP tool** — separate lifecycle management concern. +- **Per-client bearer tokens** — open item tracked in CLIENTS.md. Until they + exist, external agents share the same `OIKOS_MCP_BEARER_TOKEN`. +- **Exactly-once pattern extraction from feedback** — T32 seeds the pipeline; + the full pattern-mining algorithm (TF-IDF clustering, causal inference from + event timelines) is future work.