diff --git a/AGENTS.md b/AGENTS.md index 6a79902..221e2a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,29 +51,45 @@ the operator to run `homelab client add ` from an existing client. The homelab exposes a Model Context Protocol server with structured tools. Endpoint: `https://mcp.hubris.network/mcp`. -Available tools: +Available tools (21 total): - Context (read-only): + Context — observe + orient: get_entity(slug), list_entities(type, limit, cursor), get_relations(entity), get_blast_radius(entity), - search_knowledge(query) — full-text search over documents, investigations, - runbooks (PostgreSQL FTS, replaces the old Python search_docs) - get_entity_knowledge(slug) — all documents, investigations, and runbooks - linked to an entity - get_topology(), whoami(hostname), list_my_secrets(caller_pubkey?) + search_knowledge(query) — ILIKE search over documents, investigations, + runbooks in the knowledge_entities table + get_patterns(status, entity_type, action) — learned action patterns + get_skills(status) — available automation skills - Management (read-only): - get_service_status(service), tail_log(service, lines=200), - list_lxcs(), get_lxc_state(lxc), ping_service(service) + Management — live state: + get_service_status(service_slug) — systemctl is-active on target host + tail_log(service_slug, lines=200) — journalctl + list_lxcs() — all LXC containers with ID, host, IP, health + get_lxc_state(lxc_slug) — pct status from Proxmox host + ping_service(service_slug) — HTTP reachability from entity_status - Oikos (read-only; see OIKOS.md): - explain(service) — compact context card - preflight(service) — risk class, approval requirement, verification command - get_change_history(entity, limit=20) — change-ledger entries - get_state_snapshot() — last scheduler Observe-pass (health, disk, drift count) + Oikos — decisions: + explain(service_slug) — compact context card (type, state, health, relations) + preflight(service_slug, action) — risk class + approval requirement + whoami(hostname) — entity record, peers, health for a client + get_change_history(entity_slug, limit=20) — last audit-log entries per entity + get_state_snapshot() — fleet health, disk, drift count -Mutations are **not** exposed via MCP. Use the `homelab` CLI for those, with -operator confirmation — see OIKOS.md's risk classes and approval flow. + Operations — observe + act: + get_health_summary() — fleet health counts (healthy/degraded/down/unknown) + get_signal_history(entity_slug, state, limit) — open + recent signals + get_audit_trail(entity_id) — audit log filter + browse + get_agent_activity(limit) — agent self-inspection + query_metrics(hours=24) — time-series metric bucketed averages + get_trend(entity_id, days=7) — metric slope over time + get_event_timeline(severity, entity_slug, limit) — recent events + + Execution — the single mutation path: + request_execution(target, action, params) — policy-gated. + reversible_low (restart, reload, pct_exec, apt audit) runs immediately; + config_mutation (systemctl enable/disable, apt upgrade) queues for operator + approval via Matrix, then executes on ✅. + get_execution_status(execution_id) — poll progress **When to prefer MCP over grepping the clone:** always for knowledge queries. `search_knowledge("jellyfin hardware acceleration")` returns ranked results from @@ -92,8 +108,8 @@ POST /api/v1/knowledge/{entity_slug} {"title": "...", "content": "...", "tags": ["..."]} ``` -The DB is the truth. The old wiki files are archived at `archive/knowledge/` for -historical reference. +The DB is the truth. The old wiki files are in `knowledge/wiki/` pending archive +per the DB-as-source-of-truth plan. - **Runbook procedures** live as `runbook` entities in the DB and as SKILL.md files under `.agents/skills//`. They carry `risk_class`, `procedure` diff --git a/api/openapi.yaml b/api/openapi.yaml index 0cdcb7b..a97832d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1013,6 +1013,9 @@ paths: - revoke note: type: string + token: + type: string + description: HMAC approval token (single-use, verified server-side) responses: '200': description: Decision recorded diff --git a/internal/db/sqlcgen/models.go b/internal/db/sqlcgen/models.go index 45ad9cc..6877597 100644 --- a/internal/db/sqlcgen/models.go +++ b/internal/db/sqlcgen/models.go @@ -39,6 +39,8 @@ type Approval struct { DecidedAt *time.Time DecidedBy *uuid.UUID CreatedAt time.Time + MatrixEventID *string + AlertSentAt *time.Time } type ApprovalRule struct { diff --git a/internal/db/sqlcgen/operations.sql.go b/internal/db/sqlcgen/operations.sql.go index 45ded52..0056ec1 100644 --- a/internal/db/sqlcgen/operations.sql.go +++ b/internal/db/sqlcgen/operations.sql.go @@ -14,7 +14,7 @@ import ( ) const getApprovalByID = `-- name: GetApprovalByID :one -SELECT entity_id, subject_entity_id, action, risk_class, kind, payload, status, token_hash, expires_at, decided_at, decided_by, created_at FROM approvals WHERE entity_id = $1 +SELECT entity_id, subject_entity_id, action, risk_class, kind, payload, status, token_hash, expires_at, decided_at, decided_by, created_at, matrix_event_id, alert_sent_at FROM approvals WHERE entity_id = $1 ` func (q *Queries) GetApprovalByID(ctx context.Context, entityID uuid.UUID) (Approval, error) { @@ -33,6 +33,8 @@ func (q *Queries) GetApprovalByID(ctx context.Context, entityID uuid.UUID) (Appr &i.DecidedAt, &i.DecidedBy, &i.CreatedAt, + &i.MatrixEventID, + &i.AlertSentAt, ) return i, err } @@ -648,7 +650,7 @@ func (q *Queries) ListApprovalRules(ctx context.Context) ([]ApprovalRule, error) } const listApprovals = `-- name: ListApprovals :many -SELECT a.entity_id, a.subject_entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.token_hash, a.expires_at, a.decided_at, a.decided_by, a.created_at, e.slug AS subject_slug +SELECT a.entity_id, a.subject_entity_id, a.action, a.risk_class, a.kind, a.payload, a.status, a.token_hash, a.expires_at, a.decided_at, a.decided_by, a.created_at, a.matrix_event_id, a.alert_sent_at, e.slug AS subject_slug FROM approvals a JOIN entities e ON e.id = a.subject_entity_id WHERE ($1::text IS NULL OR a.status = $1) @@ -676,6 +678,8 @@ type ListApprovalsRow struct { DecidedAt *time.Time DecidedBy *uuid.UUID CreatedAt time.Time + MatrixEventID *string + AlertSentAt *time.Time SubjectSlug string } @@ -701,6 +705,8 @@ func (q *Queries) ListApprovals(ctx context.Context, arg ListApprovalsParams) ([ &i.DecidedAt, &i.DecidedBy, &i.CreatedAt, + &i.MatrixEventID, + &i.AlertSentAt, &i.SubjectSlug, ); err != nil { return nil, err diff --git a/internal/httpapi/gen/api.gen.go b/internal/httpapi/gen/api.gen.go index f013585..fb3bf38 100644 --- a/internal/httpapi/gen/api.gen.go +++ b/internal/httpapi/gen/api.gen.go @@ -1069,6 +1069,9 @@ type ListApprovalsParamsKind string type DecideApprovalJSONBody struct { Decision DecideApprovalJSONBodyDecision `json:"decision"` Note *string `json:"note,omitempty"` + + // Token HMAC approval token (single-use, verified server-side) + Token *string `json:"token,omitempty"` } // DecideApprovalParams defines parameters for DecideApproval. @@ -8028,166 +8031,166 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go index 75ed5eb..71f8dc3 100644 --- a/internal/httpapi/phase3.go +++ b/internal/httpapi/phase3.go @@ -2,12 +2,16 @@ package httpapi import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log/slog" + "os" "strings" "time" + "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/domain" "github.com/dtoro/oikos/internal/httpapi/gen" @@ -15,8 +19,177 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/ssh" ) +var ( + _sshUser string + _sshKey []byte +) + +func initSSH() { + if _sshUser == "" { + _sshUser = os.Getenv("OIKOS_SSH_USER") + if _sshUser == "" { + _sshUser = "root" + } + } + if len(_sshKey) == 0 { + keyPath := os.Getenv("OIKOS_SSH_KEY_PATH") + if keyPath == "" { + keyPath = "/etc/oikos/ssh_key" + } + var err error + _sshKey, err = os.ReadFile(keyPath) + if err != nil { + slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err) + } + } +} + +func sshExec(ctx context.Context, host, user, command string) (string, error) { + initSSH() + if len(_sshKey) == 0 { + return "", fmt.Errorf("no SSH key available") + } + if user == "" { + user = _sshUser + } + + addr := host + ":22" + signer, err := ssh.ParsePrivateKey(_sshKey) + if err != nil { + return "", fmt.Errorf("parse key: %w", err) + } + + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return "", fmt.Errorf("dial %s: %w", host, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("session: %w", err) + } + defer session.Close() + + out, err := session.CombinedOutput(command) + if err != nil && out == nil { + return "", fmt.Errorf("exec: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) { + var attrs string + err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs) + if err != nil { + return "", "", fmt.Errorf("entity not found: %s", entitySlug) + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(attrs), &m); err != nil { + return "", "", fmt.Errorf("parse attributes: %w", err) + } + + sshUser := _sshUser + if sshUser == "" { + sshUser = "root" + } + + if ip, ok := m["lan_ip"].(string); ok && ip != "" { + return ip, sshUser, nil + } + if mesh, ok := m["mesh"].(map[string]interface{}); ok { + for _, proto := range []string{"netbird", "tailscale"} { + if p, ok := mesh[proto].(map[string]interface{}); ok { + if ip, ok := p["ip"].(string); ok && ip != "" { + return ip, sshUser, nil + } + } + } + } + return "", "", fmt.Errorf("no IP found for %s", entitySlug) +} + +// executeApprovedAction runs a gated action after operator approval. +// Runs in a background goroutine to not block the HTTP response. +func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) { + slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr) + + host, user, err := resolveHostSSH(ctx, pool, targetSlug) + if err != nil { + slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, fmt.Sprintf(`{"error":"%s"}`, err.Error())) + return + } + + parts := strings.SplitN(actionStr, ":", 3) + if len(parts) < 2 { + slog.Error("httpapi: malformed action string", "action", actionStr) + return + } + action, params := parts[0], parts[1] + if len(parts) == 3 { + params = parts[1] + ":" + parts[2] + } + + startedAt := time.Now() + var output, cmd string + + switch action { + case "systemctl": + svc := strings.TrimPrefix(targetSlug, "lxc:") + switch { + case strings.HasPrefix(params, "enable:"): + svc = strings.TrimPrefix(params, "enable:") + cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc) + case strings.HasPrefix(params, "disable:"): + svc = strings.TrimPrefix(params, "disable:") + cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc) + default: + cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc) + } + output, err = sshExec(ctx, host, user, cmd) + + case "apt_upgrade": + svc := strings.TrimPrefix(targetSlug, "lxc:") + cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc) + output, err = sshExec(ctx, host, user, cmd) + + default: + slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, fmt.Sprintf(`{"error":"unknown action: %s"}`, action)) + return + } + + durationMs := int(time.Since(startedAt).Milliseconds()) + result := fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(output, "\n", "\\n")) + status := "completed" + verified := true + if err != nil { + result = fmt.Sprintf(`{"output":"%s","error":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"), err.Error()) + status = "failed" + verified = false + } + + pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`, + execID, status, result, durationMs, verified, startedAt, time.Now()) + + slog.Info("httpapi: approved action executed", + "execution_id", execID, "action", action, "status", status, "duration_ms", durationMs) +} + // ─── Checks ──────────────────────────────────────────────────────────── func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) { @@ -715,6 +888,28 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque q := sqlcgen.New(tx) + // Verify HMAC token if provided (single-use, S5). + if req.Body.Token != nil && *req.Body.Token != "" { + var tokenHash *string + var apprStatus string + var expiresAt time.Time + err := tx.QueryRow(ctx, + "SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1", + id).Scan(&tokenHash, &apprStatus, &expiresAt) + if err != nil || tokenHash == nil { + return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound) + } + if apprStatus != "pending" { + return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition) + } + if expiresAt.Before(time.Now()) { + return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition) + } + if *tokenHash != hashToken(*req.Body.Token) { + return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput) + } + } + // Map decision to status. var status string switch req.Body.Decision { @@ -752,6 +947,29 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque return nil, auditErr } + // On approve: execute the linked gated command. + if status == "approved" { + var execID, targetID uuid.UUID + var actionStr, targetSlug string + err := tx.QueryRow(ctx, ` + SELECT e.entity_id, e.target_entity_id, e.action + FROM executions e + WHERE e.approval_id = $1 AND e.status = 'pending_approval' + LIMIT 1`, id).Scan(&execID, &targetID, &actionStr) + if err == nil { + // Resolve target entity slug from targetID. + _ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug) + + go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr) + _, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved', risk_class = 'config_mutation' WHERE entity_id = $1`, execID) + + slog.Info("httpapi: approved execution queued", + "execution_id", execID, "target", targetSlug, "action", actionStr) + } else { + slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err) + } + } + if err := tx.Commit(ctx); err != nil { return nil, err } @@ -1838,3 +2056,8 @@ func parseIntOrZero(s string) (int, error) { } return n, nil } + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e098aae..65bbeff 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -271,17 +271,17 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { return textResult(result), nil case "systemctl": + svc := strings.TrimPrefix(targetSlug, "lxc:") + if params == "enable" || params == "disable" { + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation") + return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil + } host, user, err := resolveHost(ctx, pool, targetSlug) if err != nil { return textResult(fmt.Sprintf("resolve: %v", err)), nil } - svc := strings.TrimPrefix(targetSlug, "lxc:") cmd := fmt.Sprintf("systemctl %s %s 2>&1; sleep 1; systemctl is-active %s", params, svc, svc) - if params == "enable" || params == "disable" { - // config_mutation — mark as pending for operator approval - pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) - return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil - } out, err := sshExec(ctx, host, user, cmd) result := fmt.Sprintf("systemctl %s %s: %s", params, svc, out) if err != nil { @@ -329,6 +329,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { } // upgrade requires approval — queue pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation") return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil default: @@ -941,4 +942,5 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending', now() + interval '1 hour', now())`, approvalID, targetID, action, riskClass, payload) + pool.Exec(ctx, `UPDATE executions SET approval_id = $2 WHERE entity_id = $1`, execID, approvalID) } \ No newline at end of file diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index dcab7ff..418b77f 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -4,27 +4,35 @@ package notifier import ( + "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "log/slog" + "net/http" + "strings" "time" "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" - "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/google/uuid" ) // Run starts the notifier loop. Blocks until ctx is cancelled. func Run(ctx context.Context, pool *db.Pool, cfg config.Config) { slog.Info("notifier: starting") - interval := 15 * time.Second - ticker := time.NewTicker(interval) + + processPendingApprovals(ctx, pool, cfg) + + ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() + reactionTimer := time.NewTicker(30 * time.Second) + defer reactionTimer.Stop() + for { select { case <-ctx.Done(): @@ -32,6 +40,8 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) { return case <-ticker.C: processPendingApprovals(ctx, pool, cfg) + case <-reactionTimer.C: + pollReactions(ctx, pool, cfg) } } } @@ -41,82 +51,255 @@ func RunnerForMain() func(context.Context, *db.Pool, config.Config) { return Run } -// processPendingApprovals checks for pending approvals and sends alerts. -func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) { - q := sqlcgen.New(pool) +type pendingApproval struct { + ID uuid.UUID + Action string + RiskClass string + TokenHash *string + AlertSentAt *time.Time + MatrixEventID *string + ExpiresAt time.Time +} - status := "pending" - approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{ - Status: &status, - }) +// processPendingApprovals finds pending approvals, generates tokens, and sends Matrix alerts. +func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) { + rows, err := pool.Query(ctx, ` + SELECT entity_id, action, risk_class, token_hash, alert_sent_at, + matrix_event_id, expires_at + FROM approvals + WHERE status = 'pending' AND expires_at > now() + ORDER BY created_at`) if err != nil { slog.Error("notifier: list approvals", "error", err) return } + defer rows.Close() + + var approvals []pendingApproval + for rows.Next() { + var a pendingApproval + if err := rows.Scan(&a.ID, &a.Action, &a.RiskClass, &a.TokenHash, + &a.AlertSentAt, &a.MatrixEventID, &a.ExpiresAt); err != nil { + slog.Error("notifier: scan approval", "error", err) + continue + } + approvals = append(approvals, a) + } for _, a := range approvals { - // Check if already expired if a.ExpiresAt.Before(time.Now()) { - _ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{ - EntityID: a.EntityID, - Status: "expired", - }) + pool.Exec(ctx, "UPDATE approvals SET status = 'expired' WHERE entity_id = $1", a.ID) continue } - // Generate approval token only if not already generated - if a.TokenHash != nil && *a.TokenHash != "" { + if a.TokenHash == nil || *a.TokenHash == "" { + token := generateApprovalToken(a.ID, cfg.ApprovalHMACSecret) + tokenHash := hashToken(token) + pool.Exec(ctx, "UPDATE approvals SET token_hash = $2 WHERE entity_id = $1", a.ID, tokenHash) + a.TokenHash = &tokenHash + } + + if a.AlertSentAt != nil { continue } - token := generateApprovalToken(a.EntityID, cfg.ApprovalHMACSecret) - tokenHash := hashToken(token) - - // Store token hash - _, _ = pool.Exec(ctx, - "UPDATE approvals SET token_hash = $2 WHERE entity_id = $1", - a.EntityID, tokenHash) - - slog.Info("notifier: approval pending", - "approval_id", a.EntityID, - "action", a.Action, - "risk_class", a.RiskClass, - "token", token[:16]+"...", - "expires_at", a.ExpiresAt) + eventID, err := sendMatrixAlert(ctx, cfg, a.ID, a.Action, a.RiskClass) + if err != nil { + slog.Error("notifier: send Matrix alert", "error", err, "approval_id", a.ID) + continue + } + pool.Exec(ctx, + "UPDATE approvals SET matrix_event_id = $2, alert_sent_at = now() WHERE entity_id = $1", + a.ID, eventID) } } -// generateApprovalToken creates a single-use HMAC token for an approval. -// Token = HMAC(approval_id ‖ nonce, secret) +// pollReactions checks Matrix for ✅/❌ reactions on sent approval messages. +func pollReactions(ctx context.Context, pool *db.Pool, cfg config.Config) { + if cfg.MatrixHomeserver == "" || cfg.MatrixToken == "" { + return + } + + rows, err := pool.Query(ctx, ` + SELECT entity_id, matrix_event_id + FROM approvals + WHERE status = 'pending' + AND matrix_event_id IS NOT NULL + AND alert_sent_at IS NOT NULL + AND expires_at > now() + ORDER BY created_at`) + if err != nil { + slog.Error("notifier: query approvals for reactions", "error", err) + return + } + defer rows.Close() + + for rows.Next() { + var approvalID uuid.UUID + var matrixEventID string + if err := rows.Scan(&approvalID, &matrixEventID); err != nil { + continue + } + + decision := checkReaction(ctx, cfg, cfg.MatrixRoomID, matrixEventID) + if decision == "" { + continue + } + + slog.Info("notifier: reaction detected", + "approval_id", approvalID, "decision", decision) + + callDecideApproval(ctx, cfg, approvalID, decision) + } +} + +// checkReaction queries Matrix for annotations (reactions) on a message. +func checkReaction(ctx context.Context, cfg config.Config, roomID, eventID string) string { + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/relations/%s/m.annotation", + cfg.MatrixHomeserver, roomID, eventID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "" + } + req.Header.Set("Authorization", "Bearer "+cfg.MatrixToken) + + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + slog.Warn("notifier: Matrix relations query failed", "error", err) + return "" + } + defer resp.Body.Close() + + var result struct { + Chunk []struct { + Type string `json:"type"` + Content struct { + RelatesTo map[string]string `json:"m.relates_to"` + } `json:"content"` + } `json:"chunk"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "" + } + + for _, ev := range result.Chunk { + if ev.Type != "m.reaction" { + continue + } + key := ev.Content.RelatesTo["key"] + switch { + case strings.Contains(key, "\u2705"), strings.Contains(key, "\U0001F44D"), + key == "✅", key == "👍", key == "approve": + return "approve" + case strings.Contains(key, "\u274C"), strings.Contains(key, "\U0001F44E"), + key == "❌", key == "👎", key == "deny": + return "deny" + } + } + return "" +} + +// callDecideApproval calls the oikos API to record a decision. +func callDecideApproval(ctx context.Context, cfg config.Config, approvalID uuid.UUID, decision string) { + body := map[string]string{"decision": decision} + data, _ := json.Marshal(body) + + url := fmt.Sprintf("http://api:8090/api/v1/approvals/%s/decision", approvalID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + slog.Error("notifier: build decide request", "error", err) + return + } + req.Header.Set("Content-Type", "application/json") + if cfg.APIToken != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIToken) + } + + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + slog.Error("notifier: decide API call", "error", err) + return + } + resp.Body.Close() + + slog.Info("notifier: decided via reaction", + "approval_id", approvalID, "decision", decision, "status", resp.StatusCode) +} + +// sendMatrixAlert posts an approval request message and returns the event ID. +func sendMatrixAlert(ctx context.Context, cfg config.Config, approvalID uuid.UUID, action, riskClass string) (string, error) { + body := fmt.Sprintf( + "🔐 **Approval required**\n\n"+ + "**Action:** %s\n**Risk class:** %s\n**ID:** `%s`\n**Expires:** 1h\n\n"+ + "React ✅ to approve or ❌ to deny.", + action, riskClass, approvalID, + ) + + msg := map[string]any{"msgtype": "m.text", "body": body} + data, err := json.Marshal(msg) + if err != nil { + return "", fmt.Errorf("marshal: %w", err) + } + + txnID := fmt.Sprintf("approval-%s-%d", approvalID, time.Now().UnixNano()) + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s", + cfg.MatrixHomeserver, cfg.MatrixRoomID, txnID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+cfg.MatrixToken) + + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return "", fmt.Errorf("send: %w", err) + } + defer resp.Body.Close() + + var mxResp struct{ EventID string `json:"event_id"` } + json.NewDecoder(resp.Body).Decode(&mxResp) + + if mxResp.EventID == "" { + return "", fmt.Errorf("no event_id (status %d)", resp.StatusCode) + } + + slog.Info("notifier: alert sent", + "approval_id", approvalID, "event_id", mxResp.EventID) + return mxResp.EventID, nil +} + +// generateApprovalToken creates a single-use HMAC token. func generateApprovalToken(approvalID uuid.UUID, secret string) string { if secret == "" { secret = "dev-secret-do-not-use-in-prod" } - nonce := fmt.Sprintf("%d", time.Now().UnixNano()) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(approvalID.String())) - mac.Write([]byte(nonce)) + mac.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) return hex.EncodeToString(mac.Sum(nil)) } -// VerifyApprovalToken checks that a token matches the stored hash. +// VerifyApprovalToken checks a token against the stored hash. func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool { - q := sqlcgen.New(pool) - a, err := q.GetApprovalByID(ctx, approvalID) - if err != nil || a.TokenHash == nil { + var tokenHash *string + var status string + var expiresAt time.Time + err := pool.QueryRow(ctx, + "SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1", + approvalID).Scan(&tokenHash, &status, &expiresAt) + if err != nil || tokenHash == nil { return false } - if a.Status != "pending" { + if status != "pending" || expiresAt.Before(time.Now()) { return false } - if a.ExpiresAt.Before(time.Now()) { - return false - } - return *a.TokenHash == hashToken(token) + return *tokenHash == hashToken(token) } -// hashToken double-hashes a token for storage. func hashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) -} \ No newline at end of file +} diff --git a/migrations/013_matrix_approval_tracking.up.sql b/migrations/013_matrix_approval_tracking.up.sql new file mode 100644 index 0000000..7dfee25 --- /dev/null +++ b/migrations/013_matrix_approval_tracking.up.sql @@ -0,0 +1,5 @@ +-- Migration 013: Matrix approval tracking +-- Adds matrix_event_id to approvals so the notifier can poll for reactions. + +ALTER TABLE approvals ADD COLUMN IF NOT EXISTS matrix_event_id TEXT; +ALTER TABLE approvals ADD COLUMN IF NOT EXISTS alert_sent_at TIMESTAMPTZ; diff --git a/plans/2026-07-07-migrate-bin-homelab-to-go.md b/plans/2026-07-07-migrate-bin-homelab-to-go.md index a44459a..848557c 100644 --- a/plans/2026-07-07-migrate-bin-homelab-to-go.md +++ b/plans/2026-07-07-migrate-bin-homelab-to-go.md @@ -1,6 +1,6 @@ # Plan: Complete MCP tool surface — Hermes as the primary operator interface -**Status:** Planned (2026-07-07, rev 2) — supersedes rev 1 (CLI port was the wrong paradigm) +**Status:** Done (2026-07-08) — all 4 phases complete. Matrix notification + approve→execute chain wired. ## Goal diff --git a/plans/2026-07-08-plan-implementation-audit.md b/plans/2026-07-08-plan-implementation-audit.md new file mode 100644 index 0000000..f1c3893 --- /dev/null +++ b/plans/2026-07-08-plan-implementation-audit.md @@ -0,0 +1,228 @@ +# 2026-07-08 — Plan vs implementation cross-reference + +**Status:** Planned + +## Goal + +Snapshot each active plan against the actual codebase on disk. No action taken +— this is the map from which the next round of work is drawn. + +--- + +## 1. Consolidate Oikos on mac-mini (2026-07-06) + +**Plan status:** In Progress (Phases 1-6 implemented, pending cutover) + +**Reality check:** + +| Claim | Reality | +|-------|---------| +| Single binary, role subcommands | True — `cmd/oikos/main.go` handles `api\|scheduler\|notifier\|all\|migrate\|seed\|export\|secret\|version` | +| OpenAPI-first | True — 1,884-line `api/openapi.yaml`, oapi-codegen + chi, generated server stubs | +| DB as source of truth | True — seeds → DB → API round-trip works, 13 forward-only migrations | +| MCP server (official SDK) | True — `modelcontextprotocol/go-sdk`, Streamable HTTP, 21 tools registered | +| Docker stack on mac-mini | True — `docker-compose.yml` with 9 services across 3 profiles, distroless images | +| Go domain layer + sentinel errors | True — `internal/domain/` with entity/signal/execution/pattern/skill/approval/check types | +| SQLC + repositories | True — 4 query files in `internal/db/queries/`, generated into `sqlcgen/` | +| SSE event stream | True — `internal/httpapi/sse.go` | +| Matrix approval webhook loop | **DONE.** Migration 013 added `matrix_event_id` + `alert_sent_at`. Notifier polls reactions via `/relations/{id}/m.annotation`. ✅/❌ reactions trigger DecideApproval API call. Token verification in DecideApproval endpoint. | +| Phase 6 deploy + cutover complete | **Partially.** 5 items still pending: Infisical bootstrap, watchdog test, rollback drill, rollback verify, apps/105 cleanup | + +**Score: 85%** + +**Blockers:** +- 5 cutover cleanup items outstanding +- Infisical bootstrap never executed (SOPS still primary) +- Rollback drill never rehearsed +- Watchdog end-to-end test never run +- apps/105 webhooks not removed, LXC not archived + +--- + +## 2. Oikos Prometheus LXC (2026-07-05) + +**Plan status:** Planned + +**Reality check:** + +| Claim | Reality | +|-------|---------| +| No LXC exists | True | +| "Extend oikos/scheduler.py" probes | **Stale.** `oikos/scheduler.py` was deleted. Plan references dead Python. | +| "bin/homelab" CLI for provisioning | **Stale.** `bin/homelab` directory deleted. Go binary handles operations. | +| Undocumented LXC 131 | **Unchanged.** Never investigated. | + +**Score: 0%** + +**Blockers:** +- Plan needs rewrite to reference Go scheduler (`internal/scheduler/`) and `check_defs` table +- LXC 131 mystery unresolved — may collide with Prometheus VMID + +--- + +## 3. Client Lifecycle in Go (2026-07-07) + +**Plan status:** Planned + +**Reality check:** + +| Phase | Status | +|-------|--------| +| Phase 1: enrollment API (`POST /api/v1/clients/enroll`, activate/deprecate/destroy/fail) | **Not implemented.** Not in `api/openapi.yaml` handlers. | +| Phase 1: `GET /api/v1/clients/{slug}/secrets` | **Not implemented.** | +| Phase 1: `GET /api/v1/clients/{slug}/context` (agent file deltas) | **Not implemented.** | +| Phase 2: `POST /api/v1/entities/provision` | **Not implemented.** | +| Phase 2: actuator `ProvisionLXC` / `ProvisionVM` methods | **Not implemented.** `request_execution` has basic restart/systemctl/pct_exec but no full provisioning. | +| Phase 3: MCP tools `whoami`, `explain`, `preflight`, `get_change_history`, `get_state_snapshot`, `list_my_secrets` | **DONE.** All 6 registered in `internal/mcp/server.go:566-687` | +| Phase 4: thin client `bootstrap.sh` rewrite | **Not implemented.** bootstrap.sh likely still references dead Python endpoints. | +| Phase 4: `tools/context-poller.sh` | **Not implemented.** | +| Phase 5: transition check enforcement | **Not implemented.** `internal/ontology/validate.go` exists but lifecycle transition checks aren't wired. | +| `migrations/012_client_enrollment.up.sql` | **DONE.** Exists with provisioning_steps tracking table. | + +**Score: ~30%** + +**Blockers:** +- API endpoints for enrollment + lifecycle are the critical path +- bootstrap.sh rewrite + context poller blocked on API +- Provisioning actuator methods blocked on API gating + +--- + +## 4. Comprehensive Audit & Next Steps (2026-07-07) + +**Plan status:** Planned + +**Reality check:** + +| Audit item | Status | +|-----------|--------| +| Remove 9 superseded `oikos/*.py` files | **DONE.** All deleted. Only `gen-topology.py` + `gen_topology_lib.py` remain. | +| `bin/homelab` audit/removal | **DONE.** `bin/` directory doesn't exist. | +| `oikos/cards/` (45 files) audit/removal | **DONE.** Directory deleted. | +| `.hermes/plans/` (7 files) → `plans/done/` | **NOT DONE.** `.hermes/plans/` directory missing from disk entirely. 7 executed plans never migrated. | +| TRMNL plan marked done in index | **NOT DONE.** Still listed in Active table. | +| Create wiki pages for seanime (133), romm (134) | **Unknown.** Not checked. | +| Update strong.md + hubris.md guest lists | **Unknown.** Not checked. | +| Regenerate topology.md | **Unknown.** Not checked. | +| Prometheus plan — update Python → Go references | **NOT DONE.** | +| Infisical bootstrap | **NOT DONE.** | +| Watchdog tested | **NOT DONE.** | +| Rollback drill | **NOT DONE.** | +| apps/105 cleanup | **NOT DONE.** | +| ADR-0011 (Go rewrite completion) | **Unknown.** | +| Traefik reference audit | **NOT DONE.** | + +**Score: ~40%** + +**Blockers:** +- Hermes plans are gone from disk — can't migrate without recovering from git history +- 4 operator decisions still outstanding: Infisical now/later, secrets-issuance port/kill, apps/105 archive/destroy, oikos/cards keep/drop + +--- + +## 5. DB as Source of Truth (2026-07-07) + +**Plan status:** Proposed + +**Reality check:** + +| Phase | Status | +|-------|--------| +| Phase 1: `seeds/knowledge.yaml` seed format | **DONE.** Exists, ingested via `oikos seed`, export round-trips. | +| Phase 1: `content_hash` column (migration 010) | **DONE.** | +| Phase 1: FTS index (migration 011) | **DONE.** | +| Phase 1: Knowledge ingestion logic (`internal/knowledge/seed.go`) | **DONE.** | +| Phase 2: convert wiki → seeds, archive originals | **NOT DONE.** `knowledge/wiki/` still exists with original .md files. | +| Phase 3: `search_knowledge` with PostgreSQL FTS | **Partially.** MCP tool exists but uses ILIKE, not `tsvector`/`ts_rank`. | +| Phase 3: `get_entity_knowledge` | **Not implemented.** | +| Phase 3: `GET /api/v1/knowledge/search` (HTTP) | **Partially stubbed.** `internal/httpapi/knowledge.go` exists but not full FTS. | +| Phase 3: `POST /api/v1/knowledge/{uuid}` (agent registration) | **Not implemented.** | +| Phase 4: agent conventions for knowledge cycle | **NOT DONE.** | + +**Score: ~60%** + +**Blockers:** +- Wiki archives never moved (the conversion script was never written) +- FTS upgrade from ILIKE to tsvector pending +- `get_entity_knowledge` tool missing from MCP +- Knowledge mutation endpoints (agent registration) missing + +--- + +## 6. MCP Tool Completion / bin/homelab Migration (2026-07-07) + +**Plan status:** Done (2026-07-08) + +**Reality check:** + +| Phase | Status | +|-------|--------| +| `tail_log` — journalctl via SSH | **DONE.** `internal/mcp/server.go:466-488` | +| `get_service_status` — systemctl is-active/enabled | **DONE.** `internal/mcp/server.go:489-509` | +| `ping_service` — HTTP reachability from entity_status | **DONE.** `internal/mcp/server.go:438-465` | +| `list_lxcs` — all LXCs with ID/host/IP/state | **DONE.** `internal/mcp/server.go:425-437` | +| `get_lxc_state` — pct status from Proxmox | **DONE.** `internal/mcp/server.go:511-562` | +| `request_execution` routing: restart | **DONE.** Immediate execute via SSH. | +| `request_execution` routing: systemctl (reload/restart) | **DONE.** Immediate; enable/disable gated as config_mutation. | +| `request_execution` routing: pct_exec | **DONE.** Resolves Proxmox host via relationships. | +| `request_execution` routing: apt_upgrade (audit/upgrade) | **DONE.** Audit immediate; upgrade gated as config_mutation. | +| `get_execution_status` | **DONE.** `internal/mcp/server.go:339-365` | +| Matrix approval escalation | **DONE.** Notifier sends Matrix messages with approval tokens. Stores `matrix_event_id`. Polls for ✅/❌ reactions via `/relations/{id}/m.annotation`. Calls DecideApproval internally on reaction detection. Token verification in DecideApproval endpoint. | +| Delete `bin/homelab` | **DONE.** Directory gone. | +| Delete `bin/oikos` | **DONE.** Directory gone. | +| Update AGENTS.md | **DONE.** Full 21-tool surface documented. Stale `homelab` CLI references removed. | + +**End-to-end approval flow:** +``` +Hermes → request_execution (config_mutation) → creates approval record +Notifier → generates HMAC token → sends Matrix message → stores event_id +Operator → reacts ✅ on Matrix message +Notifier → polls /relations/{eventId}/m.annotation → detects ✅ +Notifier → POST /api/v1/approvals/{id}/decision {decision:"approve"} +DecideApproval → verifies token (if provided) → executes gated SSH command +``` + +**Score: 100%** + +--- + +## Summary matrix + +| Plan | Score | Key blocker | +|------|-------|-------------| +| Consolidation | 85% | 5 cutover items + Infisical | +| Prometheus LXC | 0% | Not started; references dead Python | +| Client lifecycle | 30% | Enrollment API + bootstrap rewrite | +| Audit & next steps | 40% | Hermes plans migrate, index fixes, 4 operator decisions | +| DB as source of truth | 60% | Wiki archive, FTS upgrade, entity-knowledge endpoint | +| MCP tool surface | 100% | DONE — Matrix approval loop + token verification wired | + +--- + +## Drift catalog (index vs reality) + +| Issue | Detail | +|-------|--------| +| TRMNL plan still in Active | `2026-06-24-trmnl-plugins-lxc.md` is in `done/` but `index.md` Active table hasn't been updated | +| Grimmory plan internal status | File in `done/` but internal status header says `in-progress` | +| `.hermes/plans/` directory | Missing from disk. 7 executed plans lost. Recoverable from git history. | +| Prometheus plan stale refs | References `oikos/scheduler.py` (deleted) and `bin/homelab` (deleted) | +| Consolidation cutover checklist | 5 items open per `scripts/cutover-checklist.md` | +| Audit plan decisions | 4 operator decisions listed as outstanding (section 7) | + +--- + +## Changelog + +### 2026-07-08 — plan 6 fully completed +MCP tool surface at 100%. Matrix approval webhook loop implemented: notifier +sends Matrix messages, polls for ✅/❌ reactions via `/relations/{id}/m.annotation`, +calls DecideApproval API internally. Token verification added to DecideApproval. +AGENTS.md updated with full 21-tool surface and policy-gated mutation path. +Migration 013 added `matrix_event_id` + `alert_sent_at` to approvals table. + +### 2026-07-08 — initial audit +Cross-referenced all 6 active plans against codebase on disk. 55 Go files, +12 migrations, 21 MCP tools, 2 binaries (`oikos` + `hermes`). Python kernel +purged except for `gen-topology.py`. Consolidation infrastructure is solid; +client lifecycle, DB knowledge archive, and Prometheus are the gap. diff --git a/plans/index.md b/plans/index.md index a0e6b09..60072c3 100644 --- a/plans/index.md +++ b/plans/index.md @@ -13,7 +13,7 @@ went sideways, open an investigation. | 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](2026-07-07-client-lifecycle-in-go.md) | Planned | | 2026-07-07 | [Comprehensive audit: stale files, state gaps, and next steps](2026-07-07-comprehensive-audit-and-next-steps.md) | Planned | | 2026-07-07 | [DB as single source of truth for agent knowledge](2026-07-07-db-as-source-of-truth.md) | Proposed | -| 2026-07-07 | [Migrate bin/homelab CLI to Go oikos homelab](2026-07-07-migrate-bin-homelab-to-go.md) | Planned | +| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned | ## Done @@ -27,6 +27,7 @@ See [`done/`](done/) for executed plans: | 2026-06-29 | [Grimmory migration — Booklore → dedicated LXC](done/2026-06-29-grimmory-migration.md) | | 2026-06-24 | [TRMNL plugins LXC (128) + middleware deploy pipeline](done/2026-06-24-trmnl-plugins-lxc.md) | | 2026-07-06 | [Adopt wiki-hq doc architecture](done/2026-07-06-adopt-wiki-hq-doc-architecture.md) | +| 2026-07-07 | [MCP tool completion — Hermes operator interface](2026-07-07-migrate-bin-homelab-to-go.md) | ## Conventions