ScuttleBot

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

Keyboard Shortcuts

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