ScuttleBot

scuttlebot / internal / bots / sentinel / sentinel.go
Source Blame History 421 lines
5ac549c… lmata 1 // Package sentinel implements the sentinel bot — an LLM-powered channel
5ac549c… lmata 2 // observer that detects policy violations and posts structured incident
5ac549c… lmata 3 // reports to a moderation channel.
5ac549c… lmata 4 //
5ac549c… lmata 5 // Sentinel never takes enforcement action. It watches, judges, and reports.
5ac549c… lmata 6 // All reports are human-readable and posted to a configured mod channel
5ac549c… lmata 7 // (e.g. #moderation) so the full audit trail is IRC-native and observable.
5ac549c… lmata 8 //
5ac549c… lmata 9 // Reports have the form:
5ac549c… lmata 10 //
5ac549c… lmata 11 // [sentinel] incident in #channel | nick: <who> | severity: high | reason: <llm judgment>
5ac549c… lmata 12 package sentinel
5ac549c… lmata 13
5ac549c… lmata 14 import (
5ac549c… lmata 15 "context"
5ac549c… lmata 16 "fmt"
5ac549c… lmata 17 "log/slog"
5ac549c… lmata 18 "net"
5ac549c… lmata 19 "strconv"
5ac549c… lmata 20 "strings"
5ac549c… lmata 21 "sync"
5ac549c… lmata 22 "time"
5ac549c… lmata 23
5ac549c… lmata 24 "github.com/lrstanley/girc"
e8d318d… noreply 25
e8d318d… noreply 26 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
5ac549c… lmata 27 )
5ac549c… lmata 28
5ac549c… lmata 29 const defaultNick = "sentinel"
5ac549c… lmata 30
5ac549c… lmata 31 // LLMProvider calls a language model to evaluate channel content.
5ac549c… lmata 32 type LLMProvider interface {
5ac549c… lmata 33 Summarize(ctx context.Context, prompt string) (string, error)
5ac549c… lmata 34 }
5ac549c… lmata 35
5ac549c… lmata 36 // Config controls sentinel's behaviour.
5ac549c… lmata 37 type Config struct {
5ac549c… lmata 38 // IRCAddr is host:port of the Ergo IRC server.
5ac549c… lmata 39 IRCAddr string
5ac549c… lmata 40 // Nick is the IRC nick. Default: "sentinel".
5ac549c… lmata 41 Nick string
5ac549c… lmata 42 // Password is the SASL PLAIN passphrase.
5ac549c… lmata 43 Password string
5ac549c… lmata 44
5ac549c… lmata 45 // ModChannel is where incident reports are posted (e.g. "#moderation").
5ac549c… lmata 46 ModChannel string
5ac549c… lmata 47 // DMOperators, when true, also sends incident reports as DMs to AlertNicks.
5ac549c… lmata 48 DMOperators bool
5ac549c… lmata 49 // AlertNicks is the list of operator nicks to DM on incidents.
5ac549c… lmata 50 AlertNicks []string
5ac549c… lmata 51
5ac549c… lmata 52 // Policy is a plain-English description of what sentinel should flag.
5ac549c… lmata 53 // Example: "Flag harassment, hate speech, spam, and coordinated manipulation."
5ac549c… lmata 54 Policy string
5ac549c… lmata 55
5ac549c… lmata 56 // WindowSize is how many messages to buffer per channel before analysis.
5ac549c… lmata 57 // Default: 20.
5ac549c… lmata 58 WindowSize int
5ac549c… lmata 59 // WindowAge is the maximum age of buffered messages before a scan is forced.
5ac549c… lmata 60 // Default: 5 minutes.
5ac549c… lmata 61 WindowAge time.Duration
5ac549c… lmata 62 // CooldownPerNick is the minimum time between reports about the same nick.
5ac549c… lmata 63 // Default: 10 minutes.
5ac549c… lmata 64 CooldownPerNick time.Duration
5ac549c… lmata 65 // MinSeverity controls which severities trigger a report.
5ac549c… lmata 66 // "low", "medium", "high" — default: "medium".
5ac549c… lmata 67 MinSeverity string
3420a83… lmata 68
3420a83… lmata 69 // Channels is the list of channels to join on connect.
3420a83… lmata 70 Channels []string
5ac549c… lmata 71 }
5ac549c… lmata 72
5ac549c… lmata 73 func (c *Config) setDefaults() {
5ac549c… lmata 74 if c.Nick == "" {
5ac549c… lmata 75 c.Nick = defaultNick
5ac549c… lmata 76 }
5ac549c… lmata 77 if c.WindowSize == 0 {
5ac549c… lmata 78 c.WindowSize = 20
5ac549c… lmata 79 }
5ac549c… lmata 80 if c.WindowAge == 0 {
5ac549c… lmata 81 c.WindowAge = 5 * time.Minute
5ac549c… lmata 82 }
5ac549c… lmata 83 if c.CooldownPerNick == 0 {
5ac549c… lmata 84 c.CooldownPerNick = 10 * time.Minute
5ac549c… lmata 85 }
5ac549c… lmata 86 if c.MinSeverity == "" {
5ac549c… lmata 87 c.MinSeverity = "medium"
5ac549c… lmata 88 }
5ac549c… lmata 89 if c.Policy == "" {
5ac549c… lmata 90 c.Policy = "Flag harassment, hate speech, spam, threats, and coordinated manipulation."
5ac549c… lmata 91 }
5ac549c… lmata 92 if c.ModChannel == "" {
5ac549c… lmata 93 c.ModChannel = "#moderation"
5ac549c… lmata 94 }
5ac549c… lmata 95 }
5ac549c… lmata 96
5ac549c… lmata 97 // msgEntry is a buffered channel message.
5ac549c… lmata 98 type msgEntry struct {
57db3d6… lmata 99 at time.Time
57db3d6… lmata 100 nick string
57db3d6… lmata 101 text string
5ac549c… lmata 102 }
5ac549c… lmata 103
5ac549c… lmata 104 // chanBuffer holds unanalysed messages for a channel.
5ac549c… lmata 105 type chanBuffer struct {
57db3d6… lmata 106 msgs []msgEntry
5ac549c… lmata 107 lastScan time.Time
5ac549c… lmata 108 }
5ac549c… lmata 109
5ac549c… lmata 110 // Bot is the sentinel bot.
5ac549c… lmata 111 type Bot struct {
5ac549c… lmata 112 cfg Config
5ac549c… lmata 113 llm LLMProvider
5ac549c… lmata 114 log *slog.Logger
5ac549c… lmata 115 client *girc.Client
5ac549c… lmata 116
5ac549c… lmata 117 mu sync.Mutex
5ac549c… lmata 118 buffers map[string]*chanBuffer // channel → buffer
5ac549c… lmata 119 cooldown map[string]time.Time // "channel:nick" → last report time
5ac549c… lmata 120 }
5ac549c… lmata 121
5ac549c… lmata 122 // New creates a sentinel Bot.
5ac549c… lmata 123 func New(cfg Config, llm LLMProvider, log *slog.Logger) *Bot {
5ac549c… lmata 124 cfg.setDefaults()
5ac549c… lmata 125 return &Bot{
5ac549c… lmata 126 cfg: cfg,
5ac549c… lmata 127 llm: llm,
5ac549c… lmata 128 log: log,
5ac549c… lmata 129 buffers: make(map[string]*chanBuffer),
5ac549c… lmata 130 cooldown: make(map[string]time.Time),
5ac549c… lmata 131 }
5ac549c… lmata 132 }
5ac549c… lmata 133
5ac549c… lmata 134 // Start connects to IRC and begins observation. Blocks until ctx is done.
5ac549c… lmata 135 func (b *Bot) Start(ctx context.Context) error {
5ac549c… lmata 136 host, port, err := splitHostPort(b.cfg.IRCAddr)
5ac549c… lmata 137 if err != nil {
5ac549c… lmata 138 return fmt.Errorf("sentinel: %w", err)
5ac549c… lmata 139 }
5ac549c… lmata 140
5ac549c… lmata 141 c := girc.New(girc.Config{
d924aea… lmata 142 Server: host,
d924aea… lmata 143 Port: port,
d924aea… lmata 144 Nick: b.cfg.Nick,
d924aea… lmata 145 User: b.cfg.Nick,
d924aea… lmata 146 Name: "scuttlebot sentinel",
81587e6… lmata 147 SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
81587e6… lmata 148 PingDelay: 30 * time.Second,
81587e6… lmata 149 PingTimeout: 30 * time.Second,
5ac549c… lmata 150 })
5ac549c… lmata 151
5ac549c… lmata 152 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
f64fe5f… noreply 153 cl.Cmd.Mode(cl.GetNick(), "+B")
3420a83… lmata 154 for _, ch := range b.cfg.Channels {
3420a83… lmata 155 cl.Cmd.Join(ch)
5ac549c… lmata 156 }
5ac549c… lmata 157 cl.Cmd.Join(b.cfg.ModChannel)
3420a83… lmata 158 if b.log != nil {
3420a83… lmata 159 b.log.Info("sentinel connected", "channels", b.cfg.Channels)
3420a83… lmata 160 }
bd16e1f… lmata 161 })
bd16e1f… lmata 162
bd16e1f… lmata 163 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
bd16e1f… lmata 164 if ch := e.Last(); strings.HasPrefix(ch, "#") {
bd16e1f… lmata 165 cl.Cmd.Join(ch)
bd16e1f… lmata 166 }
bd16e1f… lmata 167 })
bd16e1f… lmata 168
e8d318d… noreply 169 router := cmdparse.NewRouter(b.cfg.Nick)
e8d318d… noreply 170 router.Register(cmdparse.Command{
e8d318d… noreply 171 Name: "report",
e8d318d… noreply 172 Usage: "REPORT [#channel]",
e8d318d… noreply 173 Description: "on-demand policy review",
e8d318d… noreply 174 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 175 })
e8d318d… noreply 176 router.Register(cmdparse.Command{
e8d318d… noreply 177 Name: "status",
e8d318d… noreply 178 Usage: "STATUS",
e8d318d… noreply 179 Description: "show current incidents",
e8d318d… noreply 180 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 181 })
e8d318d… noreply 182 router.Register(cmdparse.Command{
e8d318d… noreply 183 Name: "dismiss",
e8d318d… noreply 184 Usage: "DISMISS <incident-id>",
e8d318d… noreply 185 Description: "dismiss a false positive",
e8d318d… noreply 186 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 187 })
e8d318d… noreply 188
e8d318d… noreply 189 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
5ac549c… lmata 190 if len(e.Params) < 1 || e.Source == nil {
e8d318d… noreply 191 return
e8d318d… noreply 192 }
e8d318d… noreply 193 // Dispatch commands (DMs and channel messages).
e8d318d… noreply 194 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
e8d318d… noreply 195 cl.Cmd.Message(reply.Target, reply.Text)
5ac549c… lmata 196 return
5ac549c… lmata 197 }
5ac549c… lmata 198 channel := e.Params[0]
5ac549c… lmata 199 if !strings.HasPrefix(channel, "#") {
e8d318d… noreply 200 return // non-command DMs ignored
5ac549c… lmata 201 }
5ac549c… lmata 202 if channel == b.cfg.ModChannel {
5ac549c… lmata 203 return // don't analyse the mod channel itself
5ac549c… lmata 204 }
5ac549c… lmata 205 nick := e.Source.Name
5ac549c… lmata 206 if nick == b.cfg.Nick {
5ac549c… lmata 207 return
5ac549c… lmata 208 }
5ac549c… lmata 209 b.buffer(ctx, channel, nick, e.Last())
5ac549c… lmata 210 })
5ac549c… lmata 211
5ac549c… lmata 212 b.client = c
5ac549c… lmata 213
5ac549c… lmata 214 // Background scanner — forces analysis on aged buffers.
5ac549c… lmata 215 go b.scanLoop(ctx)
5ac549c… lmata 216
5ac549c… lmata 217 errCh := make(chan error, 1)
5ac549c… lmata 218 go func() {
5ac549c… lmata 219 if err := c.Connect(); err != nil && ctx.Err() == nil {
5ac549c… lmata 220 errCh <- err
5ac549c… lmata 221 }
5ac549c… lmata 222 }()
5ac549c… lmata 223
5ac549c… lmata 224 select {
5ac549c… lmata 225 case <-ctx.Done():
5ac549c… lmata 226 c.Close()
5ac549c… lmata 227 return nil
5ac549c… lmata 228 case err := <-errCh:
5ac549c… lmata 229 return fmt.Errorf("sentinel: irc: %w", err)
5ac549c… lmata 230 }
5ac549c… lmata 231 }
5ac549c… lmata 232
5ac549c… lmata 233 // JoinChannel joins an additional channel.
5ac549c… lmata 234 func (b *Bot) JoinChannel(channel string) {
5ac549c… lmata 235 if b.client != nil {
5ac549c… lmata 236 b.client.Cmd.Join(channel)
5ac549c… lmata 237 }
5ac549c… lmata 238 }
5ac549c… lmata 239
5ac549c… lmata 240 // buffer appends a message to the channel buffer and triggers analysis
5ac549c… lmata 241 // when the window is full.
5ac549c… lmata 242 func (b *Bot) buffer(ctx context.Context, channel, nick, text string) {
5ac549c… lmata 243 b.mu.Lock()
5ac549c… lmata 244 buf := b.buffers[channel]
5ac549c… lmata 245 if buf == nil {
5ac549c… lmata 246 buf = &chanBuffer{lastScan: time.Now()}
5ac549c… lmata 247 b.buffers[channel] = buf
5ac549c… lmata 248 }
5ac549c… lmata 249 buf.msgs = append(buf.msgs, msgEntry{at: time.Now(), nick: nick, text: text})
5ac549c… lmata 250 ready := len(buf.msgs) >= b.cfg.WindowSize
5ac549c… lmata 251 if ready {
5ac549c… lmata 252 msgs := buf.msgs
5ac549c… lmata 253 buf.msgs = nil
5ac549c… lmata 254 buf.lastScan = time.Now()
5ac549c… lmata 255 b.mu.Unlock()
5ac549c… lmata 256 go b.analyse(ctx, channel, msgs)
5ac549c… lmata 257 } else {
5ac549c… lmata 258 b.mu.Unlock()
5ac549c… lmata 259 }
5ac549c… lmata 260 }
5ac549c… lmata 261
5ac549c… lmata 262 // scanLoop forces analysis of stale buffers periodically.
5ac549c… lmata 263 func (b *Bot) scanLoop(ctx context.Context) {
5ac549c… lmata 264 ticker := time.NewTicker(30 * time.Second)
5ac549c… lmata 265 defer ticker.Stop()
5ac549c… lmata 266 for {
5ac549c… lmata 267 select {
5ac549c… lmata 268 case <-ctx.Done():
5ac549c… lmata 269 return
5ac549c… lmata 270 case <-ticker.C:
5ac549c… lmata 271 b.flushStale(ctx)
5ac549c… lmata 272 }
5ac549c… lmata 273 }
5ac549c… lmata 274 }
5ac549c… lmata 275
5ac549c… lmata 276 func (b *Bot) flushStale(ctx context.Context) {
5ac549c… lmata 277 b.mu.Lock()
5ac549c… lmata 278 var work []struct {
5ac549c… lmata 279 channel string
5ac549c… lmata 280 msgs []msgEntry
5ac549c… lmata 281 }
5ac549c… lmata 282 for ch, buf := range b.buffers {
5ac549c… lmata 283 if len(buf.msgs) == 0 {
5ac549c… lmata 284 continue
5ac549c… lmata 285 }
5ac549c… lmata 286 if time.Since(buf.lastScan) >= b.cfg.WindowAge {
5ac549c… lmata 287 work = append(work, struct {
5ac549c… lmata 288 channel string
5ac549c… lmata 289 msgs []msgEntry
5ac549c… lmata 290 }{ch, buf.msgs})
5ac549c… lmata 291 buf.msgs = nil
5ac549c… lmata 292 buf.lastScan = time.Now()
5ac549c… lmata 293 }
5ac549c… lmata 294 }
5ac549c… lmata 295 b.mu.Unlock()
5ac549c… lmata 296 for _, w := range work {
5ac549c… lmata 297 go b.analyse(ctx, w.channel, w.msgs)
5ac549c… lmata 298 }
5ac549c… lmata 299 }
5ac549c… lmata 300
5ac549c… lmata 301 // analyse sends a window of messages to the LLM and reports any violations.
5ac549c… lmata 302 func (b *Bot) analyse(ctx context.Context, channel string, msgs []msgEntry) {
5ac549c… lmata 303 if b.llm == nil || len(msgs) == 0 {
5ac549c… lmata 304 return
5ac549c… lmata 305 }
5ac549c… lmata 306
5ac549c… lmata 307 prompt := b.buildPrompt(channel, msgs)
5ac549c… lmata 308 result, err := b.llm.Summarize(ctx, prompt)
5ac549c… lmata 309 if err != nil {
5ac549c… lmata 310 if b.log != nil {
5ac549c… lmata 311 b.log.Error("sentinel: llm error", "channel", channel, "err", err)
5ac549c… lmata 312 }
5ac549c… lmata 313 return
5ac549c… lmata 314 }
5ac549c… lmata 315
5ac549c… lmata 316 b.parseAndReport(channel, result)
5ac549c… lmata 317 }
5ac549c… lmata 318
5ac549c… lmata 319 // buildPrompt constructs the LLM prompt for a message window.
5ac549c… lmata 320 func (b *Bot) buildPrompt(channel string, msgs []msgEntry) string {
5ac549c… lmata 321 var sb strings.Builder
5ac549c… lmata 322 fmt.Fprintf(&sb, "You are a channel moderation assistant. Your policy:\n%s\n\n", b.cfg.Policy)
5ac549c… lmata 323 fmt.Fprintf(&sb, "Review the following IRC messages from %s and identify any policy violations.\n", channel)
5ac549c… lmata 324 fmt.Fprintf(&sb, "For each violation found, respond with one line in this exact format:\n")
5ac549c… lmata 325 fmt.Fprintf(&sb, "INCIDENT | nick: <nick> | severity: low|medium|high | reason: <brief reason>\n\n")
5ac549c… lmata 326 fmt.Fprintf(&sb, "If there are no violations, respond with: CLEAN\n\n")
5ac549c… lmata 327 fmt.Fprintf(&sb, "Messages (%d):\n", len(msgs))
5ac549c… lmata 328 for _, m := range msgs {
5ac549c… lmata 329 fmt.Fprintf(&sb, "[%s] %s: %s\n", m.at.Format("15:04:05"), m.nick, m.text)
5ac549c… lmata 330 }
5ac549c… lmata 331 return sb.String()
5ac549c… lmata 332 }
5ac549c… lmata 333
5ac549c… lmata 334 // parseAndReport parses LLM output and posts reports to the mod channel.
5ac549c… lmata 335 func (b *Bot) parseAndReport(channel, result string) {
5ac549c… lmata 336 if b.client == nil {
5ac549c… lmata 337 return
5ac549c… lmata 338 }
5ac549c… lmata 339 lines := strings.Split(strings.TrimSpace(result), "\n")
5ac549c… lmata 340 for _, line := range lines {
5ac549c… lmata 341 line = strings.TrimSpace(line)
5ac549c… lmata 342 if line == "" || strings.EqualFold(line, "CLEAN") {
5ac549c… lmata 343 continue
5ac549c… lmata 344 }
5ac549c… lmata 345 if !strings.HasPrefix(strings.ToUpper(line), "INCIDENT") {
5ac549c… lmata 346 continue
5ac549c… lmata 347 }
5ac549c… lmata 348
5ac549c… lmata 349 nick, severity, reason := parseIncidentLine(line)
5ac549c… lmata 350 if !b.severityMeetsMin(severity) {
5ac549c… lmata 351 continue
5ac549c… lmata 352 }
5ac549c… lmata 353
5ac549c… lmata 354 // Cooldown check.
5ac549c… lmata 355 coolKey := channel + ":" + nick
5ac549c… lmata 356 b.mu.Lock()
5ac549c… lmata 357 if last, ok := b.cooldown[coolKey]; ok && time.Since(last) < b.cfg.CooldownPerNick {
5ac549c… lmata 358 b.mu.Unlock()
5ac549c… lmata 359 continue
5ac549c… lmata 360 }
5ac549c… lmata 361 b.cooldown[coolKey] = time.Now()
5ac549c… lmata 362 b.mu.Unlock()
5ac549c… lmata 363
5ac549c… lmata 364 report := fmt.Sprintf("[sentinel] incident in %s | nick: %s | severity: %s | reason: %s",
5ac549c… lmata 365 channel, nick, severity, reason)
5ac549c… lmata 366
5ac549c… lmata 367 if b.log != nil {
5ac549c… lmata 368 b.log.Warn("sentinel incident", "channel", channel, "nick", nick, "severity", severity, "reason", reason)
5ac549c… lmata 369 }
5ac549c… lmata 370 b.client.Cmd.Message(b.cfg.ModChannel, report)
5ac549c… lmata 371 if b.cfg.DMOperators {
5ac549c… lmata 372 for _, nick := range b.cfg.AlertNicks {
5ac549c… lmata 373 b.client.Cmd.Message(nick, report)
5ac549c… lmata 374 }
5ac549c… lmata 375 }
5ac549c… lmata 376 }
5ac549c… lmata 377 }
5ac549c… lmata 378
5ac549c… lmata 379 func parseIncidentLine(line string) (nick, severity, reason string) {
5ac549c… lmata 380 // Format: INCIDENT | nick: X | severity: Y | reason: Z
5ac549c… lmata 381 parts := strings.Split(line, "|")
5ac549c… lmata 382 for _, p := range parts {
5ac549c… lmata 383 p = strings.TrimSpace(p)
5ac549c… lmata 384 if kv, ok := strings.CutPrefix(p, "nick:"); ok {
5ac549c… lmata 385 nick = strings.TrimSpace(kv)
5ac549c… lmata 386 } else if kv, ok := strings.CutPrefix(p, "severity:"); ok {
5ac549c… lmata 387 severity = strings.ToLower(strings.TrimSpace(kv))
5ac549c… lmata 388 } else if kv, ok := strings.CutPrefix(p, "reason:"); ok {
5ac549c… lmata 389 reason = strings.TrimSpace(kv)
5ac549c… lmata 390 }
5ac549c… lmata 391 }
5ac549c… lmata 392 if nick == "" {
5ac549c… lmata 393 nick = "unknown"
5ac549c… lmata 394 }
5ac549c… lmata 395 if severity == "" {
5ac549c… lmata 396 severity = "medium"
5ac549c… lmata 397 }
5ac549c… lmata 398 return
5ac549c… lmata 399 }
5ac549c… lmata 400
5ac549c… lmata 401 func (b *Bot) severityMeetsMin(severity string) bool {
5ac549c… lmata 402 order := map[string]int{"low": 0, "medium": 1, "high": 2}
5ac549c… lmata 403 min := order[b.cfg.MinSeverity]
5ac549c… lmata 404 got, ok := order[severity]
5ac549c… lmata 405 if !ok {
5ac549c… lmata 406 return true // unknown severity — report it
5ac549c… lmata 407 }
5ac549c… lmata 408 return got >= min
5ac549c… lmata 409 }
5ac549c… lmata 410
5ac549c… lmata 411 func splitHostPort(addr string) (string, int, error) {
5ac549c… lmata 412 host, portStr, err := net.SplitHostPort(addr)
5ac549c… lmata 413 if err != nil {
5ac549c… lmata 414 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
5ac549c… lmata 415 }
5ac549c… lmata 416 port, err := strconv.Atoi(portStr)
5ac549c… lmata 417 if err != nil {
5ac549c… lmata 418 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
5ac549c… lmata 419 }
5ac549c… lmata 420 return host, port, nil
5ac549c… lmata 421 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button