Files
oikos/internal/notifier/notifier.go
dtoro 3823a82417 phase 4: hermes agent — MCP tools, activity logging, 'all' role, wiring fixes
- 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.
2026-07-07 16:07:08 +02:00

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[:])
}