ScuttleBot

fix: warden skips +o users, setAgentModes respects OpsChannels Two fixes to the agent mode hierarchy: 1. Warden now checks channel modes before enforcing rate limits. Users with +o or higher are exempt from warn/mute/kick. This respects the trust hierarchy defined in bootstrap.md. 2. setAgentModes now accepts EngagementConfig and honors OpsChannels. Orchestrators with OpsChannels configured get +o only on those channels and +v on the rest. When OpsChannels is empty, all channels get +o (backward compatible). Also adds ops_channels to the register and adopt request schemas so clients can specify per-channel operator scoping. Closes #132, closes #133

lmata 2026-04-04 19:53 trunk
Commit 3e3b163cf429d6a46ba410f0e35841b5a8c065f3f70d8f5d1ad468b9b6b2174e
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -10,10 +10,11 @@
1010
1111
type registerRequest struct {
1212
Nick string `json:"nick"`
1313
Type registry.AgentType `json:"type"`
1414
Channels []string `json:"channels"`
15
+ OpsChannels []string `json:"ops_channels,omitempty"`
1516
Permissions []string `json:"permissions"`
1617
RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
1718
Rules *registry.EngagementRules `json:"engagement,omitempty"`
1819
}
1920
@@ -36,10 +37,11 @@
3637
req.Type = registry.AgentTypeWorker
3738
}
3839
3940
cfg := registry.EngagementConfig{
4041
Channels: req.Channels,
42
+ OpsChannels: req.OpsChannels,
4143
Permissions: req.Permissions,
4244
}
4345
if req.RateLimit != nil {
4446
cfg.RateLimit = *req.RateLimit
4547
}
@@ -56,11 +58,11 @@
5658
writeError(w, http.StatusInternalServerError, "registration failed")
5759
return
5860
}
5961
6062
s.registry.Touch(req.Nick)
61
- s.setAgentModes(req.Nick, req.Type, cfg.Channels)
63
+ s.setAgentModes(req.Nick, req.Type, cfg)
6264
writeJSON(w, http.StatusCreated, registerResponse{
6365
Credentials: creds,
6466
Payload: payload,
6567
})
6668
}
@@ -68,10 +70,11 @@
6870
func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
6971
nick := r.PathValue("nick")
7072
var req struct {
7173
Type registry.AgentType `json:"type"`
7274
Channels []string `json:"channels"`
75
+ OpsChannels []string `json:"ops_channels,omitempty"`
7376
Permissions []string `json:"permissions"`
7477
}
7578
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7679
writeError(w, http.StatusBadRequest, "invalid request body")
7780
return
@@ -79,10 +82,11 @@
7982
if req.Type == "" {
8083
req.Type = registry.AgentTypeWorker
8184
}
8285
cfg := registry.EngagementConfig{
8386
Channels: req.Channels,
87
+ OpsChannels: req.OpsChannels,
8488
Permissions: req.Permissions,
8589
}
8690
payload, err := s.registry.Adopt(nick, req.Type, cfg)
8791
if err != nil {
8892
if strings.Contains(err.Error(), "already registered") {
@@ -91,11 +95,11 @@
9195
}
9296
s.log.Error("adopt agent", "nick", nick, "err", err)
9397
writeError(w, http.StatusInternalServerError, "adopt failed")
9498
return
9599
}
96
- s.setAgentModes(nick, req.Type, cfg.Channels)
100
+ s.setAgentModes(nick, req.Type, cfg)
97101
writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
98102
}
99103
100104
func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
101105
nick := r.PathValue("nick")
@@ -197,21 +201,40 @@
197201
return ""
198202
}
199203
}
200204
201205
// setAgentModes grants the appropriate ChanServ access for an agent on all
202
-// its assigned channels based on its type. No-op when topology is not configured
203
-// or the agent type doesn't warrant a mode.
204
-func (s *Server) setAgentModes(nick string, agentType registry.AgentType, channels []string) {
206
+// its assigned channels based on its type. For orchestrators with OpsChannels
207
+// configured, +o is granted only on those channels and +v on the rest.
208
+// No-op when topology is not configured or the agent type doesn't warrant a mode.
209
+func (s *Server) setAgentModes(nick string, agentType registry.AgentType, cfg registry.EngagementConfig) {
205210
if s.topoMgr == nil {
206211
return
207212
}
208213
level := agentModeLevel(agentType)
209214
if level == "" {
210215
return
211216
}
212
- for _, ch := range channels {
217
+
218
+ // Orchestrators with explicit OpsChannels get +o only on those channels
219
+ // and +v on remaining channels.
220
+ if level == "OP" && len(cfg.OpsChannels) > 0 {
221
+ opsSet := make(map[string]struct{}, len(cfg.OpsChannels))
222
+ for _, ch := range cfg.OpsChannels {
223
+ opsSet[ch] = struct{}{}
224
+ }
225
+ for _, ch := range cfg.Channels {
226
+ if _, isOps := opsSet[ch]; isOps {
227
+ s.topoMgr.GrantAccess(nick, ch, "OP")
228
+ } else {
229
+ s.topoMgr.GrantAccess(nick, ch, "VOICE")
230
+ }
231
+ }
232
+ return
233
+ }
234
+
235
+ for _, ch := range cfg.Channels {
213236
s.topoMgr.GrantAccess(nick, ch, level)
214237
}
215238
}
216239
217240
// removeAgentModes revokes ChanServ access for an agent on all its assigned
218241
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -10,10 +10,11 @@
10
11 type registerRequest struct {
12 Nick string `json:"nick"`
13 Type registry.AgentType `json:"type"`
14 Channels []string `json:"channels"`
 
15 Permissions []string `json:"permissions"`
16 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
17 Rules *registry.EngagementRules `json:"engagement,omitempty"`
18 }
19
@@ -36,10 +37,11 @@
36 req.Type = registry.AgentTypeWorker
37 }
38
39 cfg := registry.EngagementConfig{
40 Channels: req.Channels,
 
41 Permissions: req.Permissions,
42 }
43 if req.RateLimit != nil {
44 cfg.RateLimit = *req.RateLimit
45 }
@@ -56,11 +58,11 @@
56 writeError(w, http.StatusInternalServerError, "registration failed")
57 return
58 }
59
60 s.registry.Touch(req.Nick)
61 s.setAgentModes(req.Nick, req.Type, cfg.Channels)
62 writeJSON(w, http.StatusCreated, registerResponse{
63 Credentials: creds,
64 Payload: payload,
65 })
66 }
@@ -68,10 +70,11 @@
68 func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
69 nick := r.PathValue("nick")
70 var req struct {
71 Type registry.AgentType `json:"type"`
72 Channels []string `json:"channels"`
 
73 Permissions []string `json:"permissions"`
74 }
75 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
76 writeError(w, http.StatusBadRequest, "invalid request body")
77 return
@@ -79,10 +82,11 @@
79 if req.Type == "" {
80 req.Type = registry.AgentTypeWorker
81 }
82 cfg := registry.EngagementConfig{
83 Channels: req.Channels,
 
84 Permissions: req.Permissions,
85 }
86 payload, err := s.registry.Adopt(nick, req.Type, cfg)
87 if err != nil {
88 if strings.Contains(err.Error(), "already registered") {
@@ -91,11 +95,11 @@
91 }
92 s.log.Error("adopt agent", "nick", nick, "err", err)
93 writeError(w, http.StatusInternalServerError, "adopt failed")
94 return
95 }
96 s.setAgentModes(nick, req.Type, cfg.Channels)
97 writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
98 }
99
100 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
101 nick := r.PathValue("nick")
@@ -197,21 +201,40 @@
197 return ""
198 }
199 }
200
201 // setAgentModes grants the appropriate ChanServ access for an agent on all
202 // its assigned channels based on its type. No-op when topology is not configured
203 // or the agent type doesn't warrant a mode.
204 func (s *Server) setAgentModes(nick string, agentType registry.AgentType, channels []string) {
 
205 if s.topoMgr == nil {
206 return
207 }
208 level := agentModeLevel(agentType)
209 if level == "" {
210 return
211 }
212 for _, ch := range channels {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213 s.topoMgr.GrantAccess(nick, ch, level)
214 }
215 }
216
217 // removeAgentModes revokes ChanServ access for an agent on all its assigned
218
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -10,10 +10,11 @@
10
11 type registerRequest struct {
12 Nick string `json:"nick"`
13 Type registry.AgentType `json:"type"`
14 Channels []string `json:"channels"`
15 OpsChannels []string `json:"ops_channels,omitempty"`
16 Permissions []string `json:"permissions"`
17 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
18 Rules *registry.EngagementRules `json:"engagement,omitempty"`
19 }
20
@@ -36,10 +37,11 @@
37 req.Type = registry.AgentTypeWorker
38 }
39
40 cfg := registry.EngagementConfig{
41 Channels: req.Channels,
42 OpsChannels: req.OpsChannels,
43 Permissions: req.Permissions,
44 }
45 if req.RateLimit != nil {
46 cfg.RateLimit = *req.RateLimit
47 }
@@ -56,11 +58,11 @@
58 writeError(w, http.StatusInternalServerError, "registration failed")
59 return
60 }
61
62 s.registry.Touch(req.Nick)
63 s.setAgentModes(req.Nick, req.Type, cfg)
64 writeJSON(w, http.StatusCreated, registerResponse{
65 Credentials: creds,
66 Payload: payload,
67 })
68 }
@@ -68,10 +70,11 @@
70 func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
71 nick := r.PathValue("nick")
72 var req struct {
73 Type registry.AgentType `json:"type"`
74 Channels []string `json:"channels"`
75 OpsChannels []string `json:"ops_channels,omitempty"`
76 Permissions []string `json:"permissions"`
77 }
78 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
79 writeError(w, http.StatusBadRequest, "invalid request body")
80 return
@@ -79,10 +82,11 @@
82 if req.Type == "" {
83 req.Type = registry.AgentTypeWorker
84 }
85 cfg := registry.EngagementConfig{
86 Channels: req.Channels,
87 OpsChannels: req.OpsChannels,
88 Permissions: req.Permissions,
89 }
90 payload, err := s.registry.Adopt(nick, req.Type, cfg)
91 if err != nil {
92 if strings.Contains(err.Error(), "already registered") {
@@ -91,11 +95,11 @@
95 }
96 s.log.Error("adopt agent", "nick", nick, "err", err)
97 writeError(w, http.StatusInternalServerError, "adopt failed")
98 return
99 }
100 s.setAgentModes(nick, req.Type, cfg)
101 writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
102 }
103
104 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
105 nick := r.PathValue("nick")
@@ -197,21 +201,40 @@
201 return ""
202 }
203 }
204
205 // setAgentModes grants the appropriate ChanServ access for an agent on all
206 // its assigned channels based on its type. For orchestrators with OpsChannels
207 // configured, +o is granted only on those channels and +v on the rest.
208 // No-op when topology is not configured or the agent type doesn't warrant a mode.
209 func (s *Server) setAgentModes(nick string, agentType registry.AgentType, cfg registry.EngagementConfig) {
210 if s.topoMgr == nil {
211 return
212 }
213 level := agentModeLevel(agentType)
214 if level == "" {
215 return
216 }
217
218 // Orchestrators with explicit OpsChannels get +o only on those channels
219 // and +v on remaining channels.
220 if level == "OP" && len(cfg.OpsChannels) > 0 {
221 opsSet := make(map[string]struct{}, len(cfg.OpsChannels))
222 for _, ch := range cfg.OpsChannels {
223 opsSet[ch] = struct{}{}
224 }
225 for _, ch := range cfg.Channels {
226 if _, isOps := opsSet[ch]; isOps {
227 s.topoMgr.GrantAccess(nick, ch, "OP")
228 } else {
229 s.topoMgr.GrantAccess(nick, ch, "VOICE")
230 }
231 }
232 return
233 }
234
235 for _, ch := range cfg.Channels {
236 s.topoMgr.GrantAccess(nick, ch, level)
237 }
238 }
239
240 // removeAgentModes revokes ChanServ access for an agent on all its assigned
241
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -307,10 +307,39 @@
307307
}
308308
if stub.grants[0].Level != "OP" {
309309
t.Errorf("level = %q, want OP", stub.grants[0].Level)
310310
}
311311
}
312
+
313
+func TestRegisterOrchestratorWithOpsChannels(t *testing.T) {
314
+ stub := &stubTopologyManager{}
315
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
316
+
317
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
318
+ "nick": "orch-ops",
319
+ "type": "orchestrator",
320
+ "channels": []string{"#fleet", "#project.foo", "#project.bar"},
321
+ "ops_channels": []string{"#fleet"},
322
+ })
323
+ defer resp.Body.Close()
324
+ if resp.StatusCode != http.StatusCreated {
325
+ t.Fatalf("register: want 201, got %d", resp.StatusCode)
326
+ }
327
+
328
+ if len(stub.grants) != 3 {
329
+ t.Fatalf("grants: want 3, got %d", len(stub.grants))
330
+ }
331
+ for i, want := range []accessCall{
332
+ {Nick: "orch-ops", Channel: "#fleet", Level: "OP"},
333
+ {Nick: "orch-ops", Channel: "#project.foo", Level: "VOICE"},
334
+ {Nick: "orch-ops", Channel: "#project.bar", Level: "VOICE"},
335
+ } {
336
+ if stub.grants[i] != want {
337
+ t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
338
+ }
339
+ }
340
+}
312341
313342
func TestRevokeRemovesAccess(t *testing.T) {
314343
stub := &stubTopologyManager{}
315344
srv, tok := newTopoTestServerWithRegistry(t, stub)
316345
317346
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -307,10 +307,39 @@
307 }
308 if stub.grants[0].Level != "OP" {
309 t.Errorf("level = %q, want OP", stub.grants[0].Level)
310 }
311 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
313 func TestRevokeRemovesAccess(t *testing.T) {
314 stub := &stubTopologyManager{}
315 srv, tok := newTopoTestServerWithRegistry(t, stub)
316
317
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -307,10 +307,39 @@
307 }
308 if stub.grants[0].Level != "OP" {
309 t.Errorf("level = %q, want OP", stub.grants[0].Level)
310 }
311 }
312
313 func TestRegisterOrchestratorWithOpsChannels(t *testing.T) {
314 stub := &stubTopologyManager{}
315 srv, tok := newTopoTestServerWithRegistry(t, stub)
316
317 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
318 "nick": "orch-ops",
319 "type": "orchestrator",
320 "channels": []string{"#fleet", "#project.foo", "#project.bar"},
321 "ops_channels": []string{"#fleet"},
322 })
323 defer resp.Body.Close()
324 if resp.StatusCode != http.StatusCreated {
325 t.Fatalf("register: want 201, got %d", resp.StatusCode)
326 }
327
328 if len(stub.grants) != 3 {
329 t.Fatalf("grants: want 3, got %d", len(stub.grants))
330 }
331 for i, want := range []accessCall{
332 {Nick: "orch-ops", Channel: "#fleet", Level: "OP"},
333 {Nick: "orch-ops", Channel: "#project.foo", Level: "VOICE"},
334 {Nick: "orch-ops", Channel: "#project.bar", Level: "VOICE"},
335 } {
336 if stub.grants[i] != want {
337 t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
338 }
339 }
340 }
341
342 func TestRevokeRemovesAccess(t *testing.T) {
343 stub := &stubTopologyManager{}
344 srv, tok := newTopoTestServerWithRegistry(t, stub)
345
346
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -138,11 +138,11 @@
138138
139139
// Bot is the warden.
140140
type Bot struct {
141141
ircAddr string
142142
password string
143
- initChannels []string // channels to join on connect
143
+ initChannels []string // channels to join on connect
144144
channelConfigs map[string]ChannelConfig // keyed by channel name
145145
defaultConfig ChannelConfig
146146
mu sync.RWMutex
147147
channels map[string]*channelState
148148
log *slog.Logger
@@ -238,10 +238,15 @@
238238
if strings.HasPrefix(strings.TrimSpace(text), "{") {
239239
cl.Cmd.Notice(nick, "warden: malformed envelope ignored (invalid JSON)")
240240
}
241241
return
242242
}
243
+
244
+ // Skip enforcement for channel ops (+o and above).
245
+ if isChannelOp(cl, channel, nick) {
246
+ return
247
+ }
243248
244249
// Rate limit check.
245250
if !cs.consume(nick) {
246251
action := cs.violation(nick)
247252
b.enforce(cl, channel, nick, action, "rate limit exceeded")
@@ -309,10 +314,24 @@
309314
cl.Cmd.Mode(channel, "+q", nick)
310315
case ActionKick:
311316
cl.Cmd.Kick(channel, nick, "warden: "+reason)
312317
}
313318
}
319
+
320
+// isChannelOp returns true if nick has +o or higher in the given channel.
321
+// Returns false if the user or channel cannot be looked up (e.g. not tracked).
322
+func isChannelOp(cl *girc.Client, channel, nick string) bool {
323
+ user := cl.LookupUser(nick)
324
+ if user == nil || user.Perms == nil {
325
+ return false
326
+ }
327
+ perms, ok := user.Perms.Lookup(channel)
328
+ if !ok {
329
+ return false
330
+ }
331
+ return perms.IsAdmin()
332
+}
314333
315334
func splitHostPort(addr string) (string, int, error) {
316335
host, portStr, err := net.SplitHostPort(addr)
317336
if err != nil {
318337
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
319338
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -138,11 +138,11 @@
138
139 // Bot is the warden.
140 type Bot struct {
141 ircAddr string
142 password string
143 initChannels []string // channels to join on connect
144 channelConfigs map[string]ChannelConfig // keyed by channel name
145 defaultConfig ChannelConfig
146 mu sync.RWMutex
147 channels map[string]*channelState
148 log *slog.Logger
@@ -238,10 +238,15 @@
238 if strings.HasPrefix(strings.TrimSpace(text), "{") {
239 cl.Cmd.Notice(nick, "warden: malformed envelope ignored (invalid JSON)")
240 }
241 return
242 }
 
 
 
 
 
243
244 // Rate limit check.
245 if !cs.consume(nick) {
246 action := cs.violation(nick)
247 b.enforce(cl, channel, nick, action, "rate limit exceeded")
@@ -309,10 +314,24 @@
309 cl.Cmd.Mode(channel, "+q", nick)
310 case ActionKick:
311 cl.Cmd.Kick(channel, nick, "warden: "+reason)
312 }
313 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
315 func splitHostPort(addr string) (string, int, error) {
316 host, portStr, err := net.SplitHostPort(addr)
317 if err != nil {
318 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
319
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -138,11 +138,11 @@
138
139 // Bot is the warden.
140 type Bot struct {
141 ircAddr string
142 password string
143 initChannels []string // channels to join on connect
144 channelConfigs map[string]ChannelConfig // keyed by channel name
145 defaultConfig ChannelConfig
146 mu sync.RWMutex
147 channels map[string]*channelState
148 log *slog.Logger
@@ -238,10 +238,15 @@
238 if strings.HasPrefix(strings.TrimSpace(text), "{") {
239 cl.Cmd.Notice(nick, "warden: malformed envelope ignored (invalid JSON)")
240 }
241 return
242 }
243
244 // Skip enforcement for channel ops (+o and above).
245 if isChannelOp(cl, channel, nick) {
246 return
247 }
248
249 // Rate limit check.
250 if !cs.consume(nick) {
251 action := cs.violation(nick)
252 b.enforce(cl, channel, nick, action, "rate limit exceeded")
@@ -309,10 +314,24 @@
314 cl.Cmd.Mode(channel, "+q", nick)
315 case ActionKick:
316 cl.Cmd.Kick(channel, nick, "warden: "+reason)
317 }
318 }
319
320 // isChannelOp returns true if nick has +o or higher in the given channel.
321 // Returns false if the user or channel cannot be looked up (e.g. not tracked).
322 func isChannelOp(cl *girc.Client, channel, nick string) bool {
323 user := cl.LookupUser(nick)
324 if user == nil || user.Perms == nil {
325 return false
326 }
327 perms, ok := user.Perms.Lookup(channel)
328 if !ok {
329 return false
330 }
331 return perms.IsAdmin()
332 }
333
334 func splitHostPort(addr string) (string, int, error) {
335 host, portStr, err := net.SplitHostPort(addr)
336 if err != nil {
337 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
338

Keyboard Shortcuts

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