ScuttleBot

scuttlebot / internal / api / policies.go
Blame History Raw 475 lines
1
package api
2
3
import (
4
"encoding/json"
5
"fmt"
6
"net/http"
7
"os"
8
"sync"
9
10
"github.com/conflicthq/scuttlebot/internal/store"
11
)
12
13
// BehaviorConfig defines a pre-registered system bot behavior.
14
type BehaviorConfig struct {
15
ID string `json:"id"`
16
Name string `json:"name"`
17
Description string `json:"description"`
18
Nick string `json:"nick"`
19
Enabled bool `json:"enabled"`
20
JoinAllChannels bool `json:"join_all_channels"`
21
ExcludeChannels []string `json:"exclude_channels"`
22
RequiredChannels []string `json:"required_channels"`
23
// Config holds bot-specific configuration. The schema is defined per bot
24
// in the UI; the backend stores and returns it opaquely.
25
Config map[string]any `json:"config,omitempty"`
26
}
27
28
// AgentPolicy defines requirements applied to all registering agents.
29
type AgentPolicy struct {
30
RequireCheckin bool `json:"require_checkin"`
31
CheckinChannel string `json:"checkin_channel"`
32
RequiredChannels []string `json:"required_channels"`
33
OnlineTimeoutSecs int `json:"online_timeout_secs,omitempty"`
34
ReapAfterDays int `json:"reap_after_days,omitempty"`
35
}
36
37
// LoggingPolicy configures message logging.
38
type LoggingPolicy struct {
39
Enabled bool `json:"enabled"`
40
Dir string `json:"dir"` // directory to write log files into
41
Format string `json:"format"` // "jsonl" | "csv" | "text"
42
Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
43
MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
44
PerChannel bool `json:"per_channel"` // separate file per channel
45
MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
46
}
47
48
// ChannelDisplayConfig holds per-channel rendering preferences.
49
type ChannelDisplayConfig struct {
50
MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal"
51
RenderMode string `json:"render_mode,omitempty"` // "rich", "text"
52
}
53
54
// BridgePolicy configures bridge-specific UI/relay behavior.
55
type BridgePolicy struct {
56
// WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
57
// visible in the channel user list after their last post.
58
WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
59
// ChannelDisplay holds per-channel rendering config.
60
ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
61
}
62
63
// PolicyLLMBackend stores an LLM backend configuration in the policy store.
64
// This allows backends to be added and edited from the web UI rather than
65
// requiring a change to scuttlebot.yaml.
66
//
67
// API keys are write-only — GET responses replace them with "***" when set.
68
type PolicyLLMBackend struct {
69
Name string `json:"name"`
70
Backend string `json:"backend"`
71
APIKey string `json:"api_key,omitempty"`
72
BaseURL string `json:"base_url,omitempty"`
73
Model string `json:"model,omitempty"`
74
Region string `json:"region,omitempty"`
75
AWSKeyID string `json:"aws_key_id,omitempty"`
76
AWSSecretKey string `json:"aws_secret_key,omitempty"`
77
Allow []string `json:"allow,omitempty"`
78
Block []string `json:"block,omitempty"`
79
Default bool `json:"default,omitempty"`
80
}
81
82
// ROETemplate is a rules-of-engagement template.
83
type ROETemplate struct {
84
Name string `json:"name"`
85
Description string `json:"description,omitempty"`
86
Channels []string `json:"channels,omitempty"`
87
Permissions []string `json:"permissions,omitempty"`
88
RateLimit struct {
89
MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
90
Burst int `json:"burst,omitempty"`
91
} `json:"rate_limit,omitempty"`
92
}
93
94
// Policies is the full mutable settings blob, persisted to policies.json.
95
type Policies struct {
96
Behaviors []BehaviorConfig `json:"behaviors"`
97
AgentPolicy AgentPolicy `json:"agent_policy"`
98
Bridge BridgePolicy `json:"bridge"`
99
Logging LoggingPolicy `json:"logging"`
100
LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
101
ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
102
OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template
103
}
104
105
// defaultBehaviors lists every built-in bot with conservative defaults (disabled).
106
var defaultBehaviors = []BehaviorConfig{
107
{
108
ID: "auditbot",
109
Name: "Auditor",
110
Description: "Immutable append-only audit trail of agent actions and credential lifecycle events.",
111
Nick: "auditbot",
112
JoinAllChannels: true,
113
},
114
{
115
ID: "scribe",
116
Name: "Scribe",
117
Description: "Records all channel messages to a structured log store.",
118
Nick: "scribe",
119
JoinAllChannels: true,
120
},
121
{
122
ID: "herald",
123
Name: "Herald",
124
Description: "Routes event notifications from external systems to IRC channels.",
125
Nick: "herald",
126
JoinAllChannels: true,
127
},
128
{
129
ID: "oracle",
130
Name: "Oracle",
131
Description: "On-demand channel summarisation via DM using an LLM.",
132
Nick: "oracle",
133
JoinAllChannels: true,
134
},
135
{
136
ID: "warden",
137
Name: "Warden",
138
Description: "Enforces channel moderation — detects floods and malformed messages, escalates warn → mute → kick.",
139
Nick: "warden",
140
JoinAllChannels: true,
141
},
142
{
143
ID: "scroll",
144
Name: "Scroll",
145
Description: "Replays channel history to users via DM on request.",
146
Nick: "scroll",
147
JoinAllChannels: true,
148
},
149
{
150
ID: "systembot",
151
Name: "Systembot",
152
Description: "Logs IRC system events (joins, parts, quits, mode changes) to a store.",
153
Nick: "systembot",
154
JoinAllChannels: true,
155
},
156
{
157
ID: "snitch",
158
Name: "Snitch",
159
Description: "Watches for erratic behaviour and alerts operators via DM or a dedicated channel.",
160
Nick: "snitch",
161
JoinAllChannels: true,
162
},
163
{
164
ID: "sentinel",
165
Name: "Sentinel",
166
Description: "LLM-powered channel observer. Detects policy violations and posts structured incident reports to a mod channel. Never takes enforcement action.",
167
Nick: "sentinel",
168
JoinAllChannels: true,
169
},
170
{
171
ID: "steward",
172
Name: "Steward",
173
Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
174
Nick: "steward",
175
JoinAllChannels: true,
176
},
177
{
178
ID: "shepherd",
179
Name: "Shepherd",
180
Description: "Goal-directed agent coordinator. Assigns work, tracks progress, checks in on agents, generates plans using LLM. Configurable with any LLM provider.",
181
Nick: "shepherd",
182
},
183
}
184
185
// BotCommand describes a single command a bot responds to.
186
type BotCommand struct {
187
Command string `json:"command"`
188
Usage string `json:"usage"`
189
Description string `json:"description"`
190
}
191
192
// botCommands maps bot ID to its available commands.
193
var botCommands = map[string][]BotCommand{
194
"oracle": {
195
{Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."},
196
},
197
"scroll": {
198
{Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."},
199
},
200
"steward": {
201
{Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."},
202
{Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."},
203
{Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."},
204
{Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."},
205
},
206
"warden": {
207
{Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."},
208
},
209
"snitch": {
210
{Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
211
},
212
"herald": {
213
{Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
214
},
215
"shepherd": {
216
{Command: "goal", Usage: "GOAL <description>", Description: "Set a goal for the current channel."},
217
{Command: "goals", Usage: "GOALS", Description: "List all active goals."},
218
{Command: "done", Usage: "DONE <goal-id>", Description: "Mark a goal as completed."},
219
{Command: "status", Usage: "STATUS", Description: "Report progress on current goals (LLM-enhanced)."},
220
{Command: "assign", Usage: "ASSIGN <nick> <task>", Description: "Manually assign a task to an agent."},
221
{Command: "checkin", Usage: "CHECKIN", Description: "Trigger a check-in round with assigned agents."},
222
{Command: "plan", Usage: "PLAN", Description: "Generate a work plan from goals using LLM."},
223
},
224
}
225
226
// PolicyStore persists Policies to a JSON file or database.
227
type PolicyStore struct {
228
mu sync.RWMutex
229
path string
230
data Policies
231
defaultBridgeTTLMinutes int
232
onChange func(Policies)
233
db *store.Store // when non-nil, supersedes path
234
}
235
236
func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) {
237
if defaultBridgeTTLMinutes <= 0 {
238
defaultBridgeTTLMinutes = 5
239
}
240
ps := &PolicyStore{
241
path: path,
242
defaultBridgeTTLMinutes: defaultBridgeTTLMinutes,
243
}
244
ps.data.Behaviors = defaultBehaviors
245
ps.data.Bridge.WebUserTTLMinutes = defaultBridgeTTLMinutes
246
if err := ps.load(); err != nil {
247
return nil, err
248
}
249
return ps, nil
250
}
251
252
func (ps *PolicyStore) load() error {
253
raw, err := os.ReadFile(ps.path)
254
if os.IsNotExist(err) {
255
return nil
256
}
257
if err != nil {
258
return fmt.Errorf("policies: read %s: %w", ps.path, err)
259
}
260
return ps.applyRaw(raw)
261
}
262
263
// SetStore switches the policy store to database-backed persistence. The
264
// current in-memory defaults are merged with any saved policies in the store.
265
func (ps *PolicyStore) SetStore(db *store.Store) error {
266
raw, err := db.PolicyGet()
267
if err != nil {
268
return fmt.Errorf("policies: load from db: %w", err)
269
}
270
ps.mu.Lock()
271
defer ps.mu.Unlock()
272
ps.db = db
273
if raw == nil {
274
return nil // no saved policies yet; keep defaults
275
}
276
return ps.applyRaw(raw)
277
}
278
279
// applyRaw merges a JSON blob into the in-memory policy state.
280
// Caller must hold ps.mu if called after initialisation.
281
func (ps *PolicyStore) applyRaw(raw []byte) error {
282
var p Policies
283
if err := json.Unmarshal(raw, &p); err != nil {
284
return fmt.Errorf("policies: parse: %w", err)
285
}
286
ps.normalize(&p)
287
// Merge saved behaviors over defaults so new built-ins appear automatically.
288
saved := make(map[string]BehaviorConfig, len(p.Behaviors))
289
for _, b := range p.Behaviors {
290
saved[b.ID] = b
291
}
292
for i, def := range ps.data.Behaviors {
293
if sv, ok := saved[def.ID]; ok {
294
ps.data.Behaviors[i] = sv
295
}
296
}
297
ps.data.AgentPolicy = p.AgentPolicy
298
ps.data.Bridge = p.Bridge
299
ps.data.Logging = p.Logging
300
ps.data.LLMBackends = p.LLMBackends
301
ps.data.ROETemplates = p.ROETemplates
302
ps.data.OnJoinMessages = p.OnJoinMessages
303
return nil
304
}
305
306
func (ps *PolicyStore) save() error {
307
raw, err := json.MarshalIndent(ps.data, "", " ")
308
if err != nil {
309
return err
310
}
311
if ps.db != nil {
312
return ps.db.PolicySet(raw)
313
}
314
return os.WriteFile(ps.path, raw, 0600)
315
}
316
317
func (ps *PolicyStore) Get() Policies {
318
ps.mu.RLock()
319
defer ps.mu.RUnlock()
320
return ps.data
321
}
322
323
// OnChange registers a callback invoked (in a new goroutine) after each
324
// successful Set(). The callback receives the new Policies snapshot.
325
func (ps *PolicyStore) OnChange(fn func(Policies)) {
326
ps.mu.Lock()
327
defer ps.mu.Unlock()
328
ps.onChange = fn
329
}
330
331
func (ps *PolicyStore) Set(p Policies) error {
332
ps.mu.Lock()
333
defer ps.mu.Unlock()
334
ps.normalize(&p)
335
ps.data = p
336
if err := ps.save(); err != nil {
337
return err
338
}
339
if ps.onChange != nil {
340
snap := ps.data
341
fn := ps.onChange
342
go fn(snap)
343
}
344
return nil
345
}
346
347
// Merge applies a partial Policies update over the current state. Only
348
// non-zero fields in the patch overwrite existing values. Behaviors are
349
// merged by ID — existing behaviors keep their defaults for fields not
350
// present in the patch.
351
func (ps *PolicyStore) Merge(patch Policies) error {
352
ps.mu.Lock()
353
defer ps.mu.Unlock()
354
355
if len(patch.Behaviors) > 0 {
356
incoming := make(map[string]BehaviorConfig, len(patch.Behaviors))
357
for _, b := range patch.Behaviors {
358
incoming[b.ID] = b
359
}
360
for i, existing := range ps.data.Behaviors {
361
if patched, ok := incoming[existing.ID]; ok {
362
// Merge: keep existing defaults, overlay patch fields.
363
if patched.Name != "" {
364
existing.Name = patched.Name
365
}
366
if patched.Description != "" {
367
existing.Description = patched.Description
368
}
369
if patched.Nick != "" {
370
existing.Nick = patched.Nick
371
}
372
existing.Enabled = patched.Enabled
373
existing.JoinAllChannels = patched.JoinAllChannels
374
if patched.ExcludeChannels != nil {
375
existing.ExcludeChannels = patched.ExcludeChannels
376
}
377
if patched.RequiredChannels != nil {
378
existing.RequiredChannels = patched.RequiredChannels
379
}
380
if patched.Config != nil {
381
existing.Config = patched.Config
382
}
383
ps.data.Behaviors[i] = existing
384
}
385
}
386
}
387
388
// Merge agent_policy if any field is set.
389
if patch.AgentPolicy.CheckinChannel != "" || patch.AgentPolicy.RequireCheckin || patch.AgentPolicy.RequiredChannels != nil {
390
if patch.AgentPolicy.CheckinChannel != "" {
391
ps.data.AgentPolicy.CheckinChannel = patch.AgentPolicy.CheckinChannel
392
}
393
ps.data.AgentPolicy.RequireCheckin = patch.AgentPolicy.RequireCheckin
394
if patch.AgentPolicy.RequiredChannels != nil {
395
ps.data.AgentPolicy.RequiredChannels = patch.AgentPolicy.RequiredChannels
396
}
397
}
398
399
// Merge bridge if set.
400
if patch.Bridge.WebUserTTLMinutes > 0 {
401
ps.data.Bridge.WebUserTTLMinutes = patch.Bridge.WebUserTTLMinutes
402
}
403
404
// Merge logging if any field is set.
405
if patch.Logging.Dir != "" || patch.Logging.Enabled {
406
ps.data.Logging = patch.Logging
407
}
408
409
// Merge LLM backends if provided.
410
if patch.LLMBackends != nil {
411
ps.data.LLMBackends = patch.LLMBackends
412
}
413
414
// Merge ROE templates if provided.
415
if patch.ROETemplates != nil {
416
ps.data.ROETemplates = patch.ROETemplates
417
}
418
419
// Merge on-join messages if provided.
420
if patch.OnJoinMessages != nil {
421
ps.data.OnJoinMessages = patch.OnJoinMessages
422
}
423
424
ps.normalize(&ps.data)
425
if err := ps.save(); err != nil {
426
return err
427
}
428
if ps.onChange != nil {
429
snap := ps.data
430
fn := ps.onChange
431
go fn(snap)
432
}
433
return nil
434
}
435
436
func (ps *PolicyStore) normalize(p *Policies) {
437
if p.Bridge.WebUserTTLMinutes <= 0 {
438
p.Bridge.WebUserTTLMinutes = ps.defaultBridgeTTLMinutes
439
}
440
}
441
442
// --- HTTP handlers ---
443
444
func (s *Server) handleGetPolicies(w http.ResponseWriter, r *http.Request) {
445
writeJSON(w, http.StatusOK, s.policies.Get())
446
}
447
448
func (s *Server) handlePutPolicies(w http.ResponseWriter, r *http.Request) {
449
var p Policies
450
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
451
writeError(w, http.StatusBadRequest, "invalid request body")
452
return
453
}
454
if err := s.policies.Set(p); err != nil {
455
s.log.Error("save policies", "err", err)
456
writeError(w, http.StatusInternalServerError, "save failed")
457
return
458
}
459
writeJSON(w, http.StatusOK, s.policies.Get())
460
}
461
462
func (s *Server) handlePatchPolicies(w http.ResponseWriter, r *http.Request) {
463
var patch Policies
464
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
465
writeError(w, http.StatusBadRequest, "invalid request body")
466
return
467
}
468
if err := s.policies.Merge(patch); err != nil {
469
s.log.Error("merge policies", "err", err)
470
writeError(w, http.StatusInternalServerError, "save failed")
471
return
472
}
473
writeJSON(w, http.StatusOK, s.policies.Get())
474
}
475

Keyboard Shortcuts

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