ScuttleBot

scuttlebot / internal / bots / auditbot / auditbot.go
Source Blame History 286 lines
8fe9b10… lmata 1 // Package auditbot implements the auditbot — immutable agent action audit trail.
8fe9b10… lmata 2 //
8fe9b10… lmata 3 // auditbot answers: "what did agent X do, and when?"
8fe9b10… lmata 4 //
8fe9b10… lmata 5 // It records two categories of events:
8fe9b10… lmata 6 // 1. IRC-observed: agent envelopes whose type appears in the configured
8fe9b10… lmata 7 // auditTypes set (e.g. task.create, agent.hello).
8fe9b10… lmata 8 // 2. Registry-injected: credential lifecycle events (registration, rotation,
8fe9b10… lmata 9 // revocation) written directly via Record(), not via IRC.
8fe9b10… lmata 10 //
8fe9b10… lmata 11 // Entries are append-only. There are no update or delete operations.
8fe9b10… lmata 12 package auditbot
8fe9b10… lmata 13
8fe9b10… lmata 14 import (
8fe9b10… lmata 15 "context"
8fe9b10… lmata 16 "fmt"
8fe9b10… lmata 17 "log/slog"
18e8fef… lmata 18 "net"
18e8fef… lmata 19 "strconv"
8fe9b10… lmata 20 "strings"
8fe9b10… lmata 21 "time"
8fe9b10… lmata 22
8fe9b10… lmata 23 "github.com/lrstanley/girc"
8fe9b10… lmata 24
e8d318d… noreply 25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
8fe9b10… lmata 26 "github.com/conflicthq/scuttlebot/pkg/protocol"
8fe9b10… lmata 27 )
8fe9b10… lmata 28
8fe9b10… lmata 29 const botNick = "auditbot"
8fe9b10… lmata 30
8fe9b10… lmata 31 // EventKind classifies the source of an audit entry.
8fe9b10… lmata 32 type EventKind string
8fe9b10… lmata 33
8fe9b10… lmata 34 const (
8fe9b10… lmata 35 // KindIRC indicates the event was observed on the IRC message stream.
8fe9b10… lmata 36 KindIRC EventKind = "irc"
8fe9b10… lmata 37 // KindRegistry indicates the event was injected by the registry.
8fe9b10… lmata 38 KindRegistry EventKind = "registry"
9460ec2… noreply 39 )
9460ec2… noreply 40
9460ec2… noreply 41 // Event types for user presence changes.
9460ec2… noreply 42 const (
9460ec2… noreply 43 EventUserJoin = "user.join"
9460ec2… noreply 44 EventUserPart = "user.part"
8fe9b10… lmata 45 )
8fe9b10… lmata 46
8fe9b10… lmata 47 // Entry is an immutable audit record.
8fe9b10… lmata 48 type Entry struct {
8fe9b10… lmata 49 At time.Time
8fe9b10… lmata 50 Kind EventKind
8fe9b10… lmata 51 Channel string // empty for registry events
8fe9b10… lmata 52 Nick string // agent nick
8fe9b10… lmata 53 MessageType string // e.g. "task.create", "agent.registered"
8fe9b10… lmata 54 MessageID string // envelope ID for IRC events; empty for registry events
8fe9b10… lmata 55 Detail string // human-readable detail (reason, etc.)
8fe9b10… lmata 56 }
8fe9b10… lmata 57
8fe9b10… lmata 58 // Store persists audit entries. Implementations must be append-only.
8fe9b10… lmata 59 type Store interface {
8fe9b10… lmata 60 Append(Entry) error
8fe9b10… lmata 61 }
8fe9b10… lmata 62
8fe9b10… lmata 63 // Bot is the auditbot.
8fe9b10… lmata 64 type Bot struct {
8fe9b10… lmata 65 ircAddr string
8fe9b10… lmata 66 password string
8fe9b10… lmata 67 channels []string
8fe9b10… lmata 68 auditTypes map[string]struct{}
8fe9b10… lmata 69 store Store
8fe9b10… lmata 70 log *slog.Logger
8fe9b10… lmata 71 client *girc.Client
8fe9b10… lmata 72 }
8fe9b10… lmata 73
8fe9b10… lmata 74 // New creates an auditbot. auditTypes is the set of message types to record;
8fe9b10… lmata 75 // pass nil or empty to audit all envelope types.
8fe9b10… lmata 76 func New(ircAddr, password string, channels []string, auditTypes []string, store Store, log *slog.Logger) *Bot {
8fe9b10… lmata 77 at := make(map[string]struct{}, len(auditTypes))
8fe9b10… lmata 78 for _, t := range auditTypes {
8fe9b10… lmata 79 at[t] = struct{}{}
8fe9b10… lmata 80 }
8fe9b10… lmata 81 return &Bot{
8fe9b10… lmata 82 ircAddr: ircAddr,
8fe9b10… lmata 83 password: password,
8fe9b10… lmata 84 channels: channels,
8fe9b10… lmata 85 auditTypes: at,
8fe9b10… lmata 86 store: store,
8fe9b10… lmata 87 log: log,
8fe9b10… lmata 88 }
8fe9b10… lmata 89 }
8fe9b10… lmata 90
8fe9b10… lmata 91 // Name returns the bot's IRC nick.
8fe9b10… lmata 92 func (b *Bot) Name() string { return botNick }
8fe9b10… lmata 93
8fe9b10… lmata 94 // Record writes a registry lifecycle event directly to the audit store.
8fe9b10… lmata 95 // This is called by the registry on registration, rotation, and revocation —
8fe9b10… lmata 96 // not from IRC.
8fe9b10… lmata 97 func (b *Bot) Record(nick, eventType, detail string) {
8fe9b10… lmata 98 b.write(Entry{
8fe9b10… lmata 99 Kind: KindRegistry,
8fe9b10… lmata 100 Nick: nick,
8fe9b10… lmata 101 MessageType: eventType,
8fe9b10… lmata 102 Detail: detail,
8fe9b10… lmata 103 })
8fe9b10… lmata 104 }
8fe9b10… lmata 105
8fe9b10… lmata 106 // Start connects to IRC and begins auditing. Blocks until ctx is cancelled.
8fe9b10… lmata 107 func (b *Bot) Start(ctx context.Context) error {
8fe9b10… lmata 108 host, port, err := splitHostPort(b.ircAddr)
8fe9b10… lmata 109 if err != nil {
8fe9b10… lmata 110 return fmt.Errorf("auditbot: parse irc addr: %w", err)
8fe9b10… lmata 111 }
8fe9b10… lmata 112
8fe9b10… lmata 113 c := girc.New(girc.Config{
d924aea… lmata 114 Server: host,
d924aea… lmata 115 Port: port,
d924aea… lmata 116 Nick: botNick,
d924aea… lmata 117 User: botNick,
d924aea… lmata 118 Name: "scuttlebot auditbot",
81587e6… lmata 119 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
81587e6… lmata 120 PingDelay: 30 * time.Second,
81587e6… lmata 121 PingTimeout: 30 * time.Second,
81587e6… lmata 122 SSL: false,
8fe9b10… lmata 123 })
8fe9b10… lmata 124
8fe9b10… lmata 125 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
f64fe5f… noreply 126 cl.Cmd.Mode(cl.GetNick(), "+B")
8fe9b10… lmata 127 for _, ch := range b.channels {
8fe9b10… lmata 128 cl.Cmd.Join(ch)
8fe9b10… lmata 129 }
8fe9b10… lmata 130 b.log.Info("auditbot connected", "channels", b.channels, "audit_types", b.auditTypesList())
8fe9b10… lmata 131 })
8fe9b10… lmata 132
bd16e1f… lmata 133 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
bd16e1f… lmata 134 if ch := e.Last(); strings.HasPrefix(ch, "#") {
bd16e1f… lmata 135 cl.Cmd.Join(ch)
bd16e1f… lmata 136 }
bd16e1f… lmata 137 })
bd16e1f… lmata 138
e8d318d… noreply 139 router := cmdparse.NewRouter(botNick)
e8d318d… noreply 140 router.Register(cmdparse.Command{
e8d318d… noreply 141 Name: "query",
e8d318d… noreply 142 Usage: "QUERY <nick|#channel>",
e8d318d… noreply 143 Description: "show recent audit events for a nick or channel",
e8d318d… noreply 144 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 145 })
e8d318d… noreply 146
e8d318d… noreply 147 c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
8fe9b10… lmata 148 if len(e.Params) < 1 {
e8d318d… noreply 149 return
e8d318d… noreply 150 }
e8d318d… noreply 151 // Dispatch commands (DMs and channel messages).
e8d318d… noreply 152 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
e8d318d… noreply 153 cl.Cmd.Message(reply.Target, reply.Text)
8fe9b10… lmata 154 return
8fe9b10… lmata 155 }
8fe9b10… lmata 156 channel := e.Params[0]
8fe9b10… lmata 157 if !strings.HasPrefix(channel, "#") {
e8d318d… noreply 158 return // non-command DMs ignored
8fe9b10… lmata 159 }
8fe9b10… lmata 160 text := e.Last()
8fe9b10… lmata 161 env, err := protocol.Unmarshal([]byte(text))
8fe9b10… lmata 162 if err != nil {
8fe9b10… lmata 163 return // non-envelope PRIVMSG ignored
8fe9b10… lmata 164 }
8fe9b10… lmata 165 if !b.shouldAudit(env.Type) {
8fe9b10… lmata 166 return
8fe9b10… lmata 167 }
8fe9b10… lmata 168 nick := ""
8fe9b10… lmata 169 if e.Source != nil {
8fe9b10… lmata 170 nick = e.Source.Name
8fe9b10… lmata 171 }
8fe9b10… lmata 172 b.write(Entry{
8fe9b10… lmata 173 Kind: KindIRC,
8fe9b10… lmata 174 Channel: channel,
8fe9b10… lmata 175 Nick: nick,
8fe9b10… lmata 176 MessageType: env.Type,
8fe9b10… lmata 177 MessageID: env.ID,
8fe9b10… lmata 178 })
8fe9b10… lmata 179 })
9460ec2… noreply 180 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
9460ec2… noreply 181 if len(e.Params) == 0 {
9460ec2… noreply 182 return
9460ec2… noreply 183 }
9460ec2… noreply 184 if !b.shouldAudit(EventUserJoin) {
9460ec2… noreply 185 return
9460ec2… noreply 186 }
9460ec2… noreply 187 channel := e.Params[0]
9460ec2… noreply 188 nick := ""
9460ec2… noreply 189 if e.Source != nil {
9460ec2… noreply 190 nick = e.Source.Name
9460ec2… noreply 191 }
9460ec2… noreply 192 b.write(Entry{
9460ec2… noreply 193 Kind: KindIRC,
9460ec2… noreply 194 Channel: channel,
9460ec2… noreply 195 Nick: nick,
9460ec2… noreply 196 MessageType: EventUserJoin,
9460ec2… noreply 197 })
9460ec2… noreply 198 })
8fe9b10… lmata 199
9460ec2… noreply 200 c.Handlers.AddBg(girc.PART, func(_ *girc.Client, e girc.Event) {
9460ec2… noreply 201 if len(e.Params) == 0 {
9460ec2… noreply 202 return
9460ec2… noreply 203 }
9460ec2… noreply 204 if !b.shouldAudit(EventUserPart) {
9460ec2… noreply 205 return
9460ec2… noreply 206 }
9460ec2… noreply 207 channel := e.Params[0]
9460ec2… noreply 208 nick := ""
9460ec2… noreply 209 if e.Source != nil {
9460ec2… noreply 210 nick = e.Source.Name
9460ec2… noreply 211 }
9460ec2… noreply 212 b.write(Entry{
9460ec2… noreply 213 Kind: KindIRC,
9460ec2… noreply 214 Channel: channel,
9460ec2… noreply 215 Nick: nick,
9460ec2… noreply 216 MessageType: EventUserPart,
9460ec2… noreply 217 })
9460ec2… noreply 218 })
8fe9b10… lmata 219 b.client = c
8fe9b10… lmata 220
8fe9b10… lmata 221 errCh := make(chan error, 1)
8fe9b10… lmata 222 go func() {
8fe9b10… lmata 223 if err := c.Connect(); err != nil && ctx.Err() == nil {
8fe9b10… lmata 224 errCh <- err
8fe9b10… lmata 225 }
8fe9b10… lmata 226 }()
8fe9b10… lmata 227
8fe9b10… lmata 228 select {
8fe9b10… lmata 229 case <-ctx.Done():
8fe9b10… lmata 230 c.Close()
8fe9b10… lmata 231 return nil
8fe9b10… lmata 232 case err := <-errCh:
8fe9b10… lmata 233 return fmt.Errorf("auditbot: irc connection: %w", err)
8fe9b10… lmata 234 }
8fe9b10… lmata 235 }
8fe9b10… lmata 236
8fe9b10… lmata 237 // Stop disconnects the bot.
8fe9b10… lmata 238 func (b *Bot) Stop() {
8fe9b10… lmata 239 if b.client != nil {
8fe9b10… lmata 240 b.client.Close()
8fe9b10… lmata 241 }
8fe9b10… lmata 242 }
8fe9b10… lmata 243
8fe9b10… lmata 244 func (b *Bot) shouldAudit(msgType string) bool {
8fe9b10… lmata 245 if len(b.auditTypes) == 0 {
8fe9b10… lmata 246 return true // audit everything when no filter configured
8fe9b10… lmata 247 }
8fe9b10… lmata 248 _, ok := b.auditTypes[msgType]
8fe9b10… lmata 249 return ok
8fe9b10… lmata 250 }
8fe9b10… lmata 251
8fe9b10… lmata 252 func (b *Bot) write(e Entry) {
8fe9b10… lmata 253 e.At = time.Now()
8fe9b10… lmata 254 if err := b.store.Append(e); err != nil {
9460ec2… noreply 255 b.log.Error("auditbot: failed to write entry",
9460ec2… noreply 256 "type", e.MessageType,
9460ec2… noreply 257 "nick", e.Nick,
9460ec2… noreply 258 "channel", e.Channel,
9460ec2… noreply 259 "kind", e.Kind,
9460ec2… noreply 260 "err", err,
9460ec2… noreply 261 )
8fe9b10… lmata 262 }
8fe9b10… lmata 263 }
8fe9b10… lmata 264
8fe9b10… lmata 265 func (b *Bot) auditTypesList() []string {
8fe9b10… lmata 266 if len(b.auditTypes) == 0 {
8fe9b10… lmata 267 return []string{"*"}
8fe9b10… lmata 268 }
8fe9b10… lmata 269 out := make([]string, 0, len(b.auditTypes))
8fe9b10… lmata 270 for t := range b.auditTypes {
8fe9b10… lmata 271 out = append(out, t)
8fe9b10… lmata 272 }
8fe9b10… lmata 273 return out
8fe9b10… lmata 274 }
8fe9b10… lmata 275
8fe9b10… lmata 276 func splitHostPort(addr string) (string, int, error) {
18e8fef… lmata 277 host, portStr, err := net.SplitHostPort(addr)
18e8fef… lmata 278 if err != nil {
8fe9b10… lmata 279 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
18e8fef… lmata 280 }
18e8fef… lmata 281 port, err := strconv.Atoi(portStr)
18e8fef… lmata 282 if err != nil {
18e8fef… lmata 283 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
8fe9b10… lmata 284 }
8fe9b10… lmata 285 return host, port, nil
8fe9b10… lmata 286 }

Keyboard Shortcuts

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