- mcp/server.go: 7 new tools (get_signal_history, get_patterns, get_skills, request_execution, get_trend, get_event_timeline, get_agent_activity), agent_activity logging middleware on every tool call. - phase3.go: QueryAgentActivity REST handler implemented (was stub). Fixed scan count mismatch in ListSkills/PatchSkill/ListSkillVersions (13 cols → 12 targets). Fixed AgentActivity cursor pagination (lexicographic → integer comparison). Fixed s.Slug → s.Name in log. - cmd/oikos/main.go: 'all' role now runs api + scheduler + notifier in one process. Replaced nil SchedulerRunner/NotifierRunner with direct scheduler.RunnerForMain() / notifier.RunnerForMain() imports. Added runWithPool helper for standalone scheduler/notifier roles. - internal/config/config.go: added HermesAgentID env var. - internal/httpapi/server.go: pass HermesAgentID to MCP handler. - docker-compose.yml: added scheduler and notifier services (dev profile). - hermes/: config.yaml, SOUL.md, skills/homelab-ops/SKILL.md. - Cleaned up: scheduler/init.go dead code, mcp/server.go pgx import guard.
122 lines
3.2 KiB
Go
122 lines
3.2 KiB
Go
// Package notifier handles alerts and approval requests via Matrix.
|
|
// Uses the DB as the rendezvous — no service-to-service calls (SA7/A7).
|
|
// Pending approvals survive restarts of either side.
|
|
package notifier
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"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)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("notifier: shutting down")
|
|
return
|
|
case <-ticker.C:
|
|
processPendingApprovals(ctx, pool, cfg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RunnerForMain provides the run function for registration in main.
|
|
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)
|
|
|
|
status := "pending"
|
|
approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{
|
|
Status: &status,
|
|
})
|
|
if err != nil {
|
|
slog.Error("notifier: list approvals", "error", err)
|
|
return
|
|
}
|
|
|
|
for _, a := range approvals {
|
|
// Check if already expired
|
|
if a.ExpiresAt.Before(time.Now()) {
|
|
_ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
|
|
EntityID: a.EntityID,
|
|
Status: "expired",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Generate approval token only if not already generated
|
|
if a.TokenHash != nil && *a.TokenHash != "" {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// generateApprovalToken creates a single-use HMAC token for an approval.
|
|
// Token = HMAC(approval_id ‖ nonce, secret)
|
|
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))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// VerifyApprovalToken checks that a token matches 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 {
|
|
return false
|
|
}
|
|
if a.Status != "pending" {
|
|
return false
|
|
}
|
|
if a.ExpiresAt.Before(time.Now()) {
|
|
return false
|
|
}
|
|
return *a.TokenHash == hashToken(token)
|
|
}
|
|
|
|
// hashToken double-hashes a token for storage.
|
|
func hashToken(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
} |