ScuttleBot

feat: agent rules-of-engagement config schema (#19) EngagementConfig: channels, ops_channels, permissions, rate_limit (messages_per_second + burst), engagement rules (respond_to_types + ignore_nicks). Full schema is signed into the EngagementPayload. Validate() rejects: channels without #, ops_channel not in join list, negative rate limits, empty respond_to_type entries. Register() validates config before any provisioning. All call sites updated. Tests: 8 validation cases + register-with-invalid-config confirms no account is created when validation fails. Closes #19

lmata 2026-03-31 06:29 trunk
Commit 78306977e7e952fcd11190febe365dc1b7d4f6582c3338337eed7f6a16186f66
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -7,14 +7,16 @@
77
88
"github.com/conflicthq/scuttlebot/internal/registry"
99
)
1010
1111
type registerRequest struct {
12
- Nick string `json:"nick"`
13
- Type registry.AgentType `json:"type"`
14
- Channels []string `json:"channels"`
15
- Permissions []string `json:"permissions"`
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"`
1618
}
1719
1820
type registerResponse struct {
1921
Credentials *registry.Credentials `json:"credentials"`
2022
Payload *registry.SignedPayload `json:"payload"`
@@ -32,11 +34,21 @@
3234
}
3335
if req.Type == "" {
3436
req.Type = registry.AgentTypeWorker
3537
}
3638
37
- creds, payload, err := s.registry.Register(req.Nick, req.Type, req.Channels, req.Permissions)
39
+ cfg := registry.EngagementConfig{
40
+ Channels: req.Channels,
41
+ Permissions: req.Permissions,
42
+ }
43
+ if req.RateLimit != nil {
44
+ cfg.RateLimit = *req.RateLimit
45
+ }
46
+ if req.Rules != nil {
47
+ cfg.Rules = *req.Rules
48
+ }
49
+ creds, payload, err := s.registry.Register(req.Nick, req.Type, cfg)
3850
if err != nil {
3951
if strings.Contains(err.Error(), "already registered") {
4052
writeError(w, http.StatusConflict, err.Error())
4153
return
4254
}
4355
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -7,14 +7,16 @@
7
8 "github.com/conflicthq/scuttlebot/internal/registry"
9 )
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 }
17
18 type registerResponse struct {
19 Credentials *registry.Credentials `json:"credentials"`
20 Payload *registry.SignedPayload `json:"payload"`
@@ -32,11 +34,21 @@
32 }
33 if req.Type == "" {
34 req.Type = registry.AgentTypeWorker
35 }
36
37 creds, payload, err := s.registry.Register(req.Nick, req.Type, req.Channels, req.Permissions)
 
 
 
 
 
 
 
 
 
 
38 if err != nil {
39 if strings.Contains(err.Error(), "already registered") {
40 writeError(w, http.StatusConflict, err.Error())
41 return
42 }
43
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -7,14 +7,16 @@
7
8 "github.com/conflicthq/scuttlebot/internal/registry"
9 )
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
20 type registerResponse struct {
21 Credentials *registry.Credentials `json:"credentials"`
22 Payload *registry.SignedPayload `json:"payload"`
@@ -32,11 +34,21 @@
34 }
35 if req.Type == "" {
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 }
46 if req.Rules != nil {
47 cfg.Rules = *req.Rules
48 }
49 creds, payload, err := s.registry.Register(req.Nick, req.Type, cfg)
50 if err != nil {
51 if strings.Contains(err.Error(), "already registered") {
52 writeError(w, http.StatusConflict, err.Error())
53 return
54 }
55
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -287,11 +287,11 @@
287287
channels = append(channels, s)
288288
}
289289
}
290290
}
291291
292
- creds, _, err := s.registry.Register(nick, agentType, channels, nil)
292
+ creds, _, err := s.registry.Register(nick, agentType, registry.EngagementConfig{Channels: channels})
293293
if err != nil {
294294
return "", err
295295
}
296296
297297
return fmt.Sprintf("Agent registered: %s\nnick: %s\npassword: %s",
298298
299299
ADDED internal/registry/engagement.go
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -287,11 +287,11 @@
287 channels = append(channels, s)
288 }
289 }
290 }
291
292 creds, _, err := s.registry.Register(nick, agentType, channels, nil)
293 if err != nil {
294 return "", err
295 }
296
297 return fmt.Sprintf("Agent registered: %s\nnick: %s\npassword: %s",
298
299 DDED internal/registry/engagement.go
--- internal/mcp/mcp.go
+++ internal/mcp/mcp.go
@@ -287,11 +287,11 @@
287 channels = append(channels, s)
288 }
289 }
290 }
291
292 creds, _, err := s.registry.Register(nick, agentType, registry.EngagementConfig{Channels: channels})
293 if err != nil {
294 return "", err
295 }
296
297 return fmt.Sprintf("Agent registered: %s\nnick: %s\npassword: %s",
298
299 DDED internal/registry/engagement.go
--- a/internal/registry/engagement.go
+++ b/internal/registry/engagement.go
@@ -0,0 +1,88 @@
1
+package registry
2
+
3
+import (
4
+ "fmt"
5
+ "strings"
6
+)
7
+
8
+// EngagementConfig is the rules-of-engagement configuration for a registered agent.
9
+// Passed to Register() at registration time; signed into the payload returned to the agent.
10
+type EngagementConfig struct {
11
+ // Channels is the list of IRC channels the agent should join.
12
+ Channels []string `json:"channels,omitempty"`
13
+
14
+ // OpsChannels is a subset of Channels where the agent is granted +o (operator).
15
+ // Only meaningful for orchestrator-type agents.
16
+ OpsChannels []string `json:"ops_channels,omitempty"`
17
+
18
+ // Permissions is the list of allowed action types (e.g. "task.create").
19
+ // Empty means no explicit restrictions.
20
+ Permissions []string `json:"permissions,omitempty"`
21
+
22
+ // RateLimit controls message throughput for this agent.
23
+ RateLimit RateLimitConfig `json:"rate_limit,omitempty"`
24
+
25
+ // Rules defines engagement behaviour rules for this agent.
26
+ Rules EngagementRules `json:"engagement,omitempty"`
27
+}
28
+
29
+// RateLimitConfig controls message throughput.
30
+type RateLimitConfig struct {
31
+ // MessagesPerSecond is the sustained send rate allowed. 0 means no limit.
32
+ MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
33
+
34
+ // Burst is the maximum burst above MessagesPerSecond. 0 means no burst.
35
+ Burst int `json:"burst,omitempty"`
36
+}
37
+
38
+// EngagementRules defines what message types and peers the agent should engage with.
39
+type EngagementRules struct {
40
+ // RespondToTypes restricts which message types trigger handler callbacks.
41
+ // Empty means respond to all types.
42
+ RespondToTypes []string `json:"respond_to_types,omitempty"`
43
+
44
+ // IgnoreNicks is a list of IRC nicks whose messages are always ignored.
45
+ IgnoreNicks []string `json:"ignore_nicks,omitempty"`
46
+}
47
+
48
+// Validate checks the EngagementConfig for obvious errors.
49
+// Returns a descriptive error for the first problem found.
50
+func (c EngagementConfig) Validate() error {
51
+ for _, ch := range c.Channels {
52
+ if !strings.HasPrefix(ch, "#") {
53
+ return fmt.Errorf("engagement: channel %q must start with #", ch)
54
+ }
55
+ if strings.ContainsAny(ch, " \t\r\n,") {
56
+ return fmt.Errorf("engagement: channel %q contains invalid characters", ch)
57
+ }
58
+ if len(ch) < 2 {
59
+ return fmt.Errorf("engagement: channel %q is too short", ch)
60
+ }
61
+ }
62
+
63
+ // OpsChannels must be a subset of Channels.
64
+ joinSet := make(map[string]struct{}, len(c.Channels))
65
+ for _, ch := range c.Channels {
66
+ joinSet[ch] = struct{}{}
67
+ }
68
+ for _, ch := range c.OpsChannels {
69
+ if _, ok := joinSet[ch]; !ok {
70
+ return fmt.Errorf("engagement: ops_channel %q is not in channels list", ch)
71
+ }
72
+ }
73
+
74
+ if c.RateLimit.MessagesPerSecond < 0 {
75
+ return fmt.Errorf("engagement: rate_limit.messages_per_second must be >= 0")
76
+ }
77
+ if c.RateLimit.Burst < 0 {
78
+ return fmt.Errorf("engagement: rate_limit.burst must be >= 0")
79
+ }
80
+
81
+ for _, t := range c.Rules.RespondToTypes {
82
+ if t == "" {
83
+ return fmt.Errorf("engagement: respond_to_types contains empty string")
84
+ }
85
+ }
86
+
87
+ return nil
88
+}
--- a/internal/registry/engagement.go
+++ b/internal/registry/engagement.go
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/registry/engagement.go
+++ b/internal/registry/engagement.go
@@ -0,0 +1,88 @@
1 package registry
2
3 import (
4 "fmt"
5 "strings"
6 )
7
8 // EngagementConfig is the rules-of-engagement configuration for a registered agent.
9 // Passed to Register() at registration time; signed into the payload returned to the agent.
10 type EngagementConfig struct {
11 // Channels is the list of IRC channels the agent should join.
12 Channels []string `json:"channels,omitempty"`
13
14 // OpsChannels is a subset of Channels where the agent is granted +o (operator).
15 // Only meaningful for orchestrator-type agents.
16 OpsChannels []string `json:"ops_channels,omitempty"`
17
18 // Permissions is the list of allowed action types (e.g. "task.create").
19 // Empty means no explicit restrictions.
20 Permissions []string `json:"permissions,omitempty"`
21
22 // RateLimit controls message throughput for this agent.
23 RateLimit RateLimitConfig `json:"rate_limit,omitempty"`
24
25 // Rules defines engagement behaviour rules for this agent.
26 Rules EngagementRules `json:"engagement,omitempty"`
27 }
28
29 // RateLimitConfig controls message throughput.
30 type RateLimitConfig struct {
31 // MessagesPerSecond is the sustained send rate allowed. 0 means no limit.
32 MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
33
34 // Burst is the maximum burst above MessagesPerSecond. 0 means no burst.
35 Burst int `json:"burst,omitempty"`
36 }
37
38 // EngagementRules defines what message types and peers the agent should engage with.
39 type EngagementRules struct {
40 // RespondToTypes restricts which message types trigger handler callbacks.
41 // Empty means respond to all types.
42 RespondToTypes []string `json:"respond_to_types,omitempty"`
43
44 // IgnoreNicks is a list of IRC nicks whose messages are always ignored.
45 IgnoreNicks []string `json:"ignore_nicks,omitempty"`
46 }
47
48 // Validate checks the EngagementConfig for obvious errors.
49 // Returns a descriptive error for the first problem found.
50 func (c EngagementConfig) Validate() error {
51 for _, ch := range c.Channels {
52 if !strings.HasPrefix(ch, "#") {
53 return fmt.Errorf("engagement: channel %q must start with #", ch)
54 }
55 if strings.ContainsAny(ch, " \t\r\n,") {
56 return fmt.Errorf("engagement: channel %q contains invalid characters", ch)
57 }
58 if len(ch) < 2 {
59 return fmt.Errorf("engagement: channel %q is too short", ch)
60 }
61 }
62
63 // OpsChannels must be a subset of Channels.
64 joinSet := make(map[string]struct{}, len(c.Channels))
65 for _, ch := range c.Channels {
66 joinSet[ch] = struct{}{}
67 }
68 for _, ch := range c.OpsChannels {
69 if _, ok := joinSet[ch]; !ok {
70 return fmt.Errorf("engagement: ops_channel %q is not in channels list", ch)
71 }
72 }
73
74 if c.RateLimit.MessagesPerSecond < 0 {
75 return fmt.Errorf("engagement: rate_limit.messages_per_second must be >= 0")
76 }
77 if c.RateLimit.Burst < 0 {
78 return fmt.Errorf("engagement: rate_limit.burst must be >= 0")
79 }
80
81 for _, t := range c.Rules.RespondToTypes {
82 if t == "" {
83 return fmt.Errorf("engagement: respond_to_types contains empty string")
84 }
85 }
86
87 return nil
88 }
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -25,33 +25,33 @@
2525
AgentTypeObserver AgentType = "observer" // no special mode
2626
)
2727
2828
// Agent is a registered agent.
2929
type Agent struct {
30
- Nick string `json:"nick"`
31
- Type AgentType `json:"type"`
32
- Channels []string `json:"channels"`
33
- Permissions []string `json:"permissions"`
34
- CreatedAt time.Time `json:"created_at"`
35
- Revoked bool `json:"revoked"`
30
+ Nick string `json:"nick"`
31
+ Type AgentType `json:"type"`
32
+ Channels []string `json:"channels"` // convenience: same as Config.Channels
33
+ Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
34
+ Config EngagementConfig `json:"config"`
35
+ CreatedAt time.Time `json:"created_at"`
36
+ Revoked bool `json:"revoked"`
3637
}
3738
3839
// Credentials are the SASL credentials an agent uses to connect to Ergo.
3940
type Credentials struct {
4041
Nick string `json:"nick"`
4142
Passphrase string `json:"passphrase"`
4243
}
4344
4445
// EngagementPayload is the signed payload delivered to an agent on registration.
45
-// It describes the agent's channel assignments, permissions, and engagement rules.
46
+// Agents verify this with VerifyPayload() before trusting its contents.
4647
type EngagementPayload struct {
47
- V int `json:"v"`
48
- Nick string `json:"nick"`
49
- Type AgentType `json:"type"`
50
- Channels []string `json:"channels"`
51
- Permissions []string `json:"permissions"`
52
- IssuedAt time.Time `json:"issued_at"`
48
+ V int `json:"v"`
49
+ Nick string `json:"nick"`
50
+ Type AgentType `json:"type"`
51
+ Config EngagementConfig `json:"config"`
52
+ IssuedAt time.Time `json:"issued_at"`
5353
}
5454
5555
// SignedPayload wraps an EngagementPayload with an HMAC signature.
5656
type SignedPayload struct {
5757
Payload EngagementPayload `json:"payload"`
@@ -82,11 +82,16 @@
8282
}
8383
}
8484
8585
// Register creates a new agent, provisions its Ergo account, and returns
8686
// credentials and a signed rules-of-engagement payload.
87
-func (r *Registry) Register(nick string, agentType AgentType, channels, permissions []string) (*Credentials, *SignedPayload, error) {
87
+// cfg is validated before any provisioning occurs.
88
+func (r *Registry) Register(nick string, agentType AgentType, cfg EngagementConfig) (*Credentials, *SignedPayload, error) {
89
+ if err := cfg.Validate(); err != nil {
90
+ return nil, nil, fmt.Errorf("registry: invalid engagement config: %w", err)
91
+ }
92
+
8893
r.mu.Lock()
8994
defer r.mu.Unlock()
9095
9196
if existing, ok := r.agents[nick]; ok && !existing.Revoked {
9297
return nil, nil, fmt.Errorf("registry: agent %q already registered", nick)
@@ -102,12 +107,13 @@
102107
}
103108
104109
agent := &Agent{
105110
Nick: nick,
106111
Type: agentType,
107
- Channels: channels,
108
- Permissions: permissions,
112
+ Channels: cfg.Channels,
113
+ Permissions: cfg.Permissions,
114
+ Config: cfg,
109115
CreatedAt: time.Now(),
110116
}
111117
r.agents[nick] = agent
112118
113119
payload, err := r.signPayload(agent)
@@ -196,16 +202,15 @@
196202
return agent, nil
197203
}
198204
199205
func (r *Registry) signPayload(agent *Agent) (*SignedPayload, error) {
200206
payload := EngagementPayload{
201
- V: 1,
202
- Nick: agent.Nick,
203
- Type: agent.Type,
204
- Channels: agent.Channels,
205
- Permissions: agent.Permissions,
206
- IssuedAt: time.Now(),
207
+ V: 1,
208
+ Nick: agent.Nick,
209
+ Type: agent.Type,
210
+ Config: agent.Config,
211
+ IssuedAt: time.Now(),
207212
}
208213
209214
data, err := json.Marshal(payload)
210215
if err != nil {
211216
return nil, err
212217
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -25,33 +25,33 @@
25 AgentTypeObserver AgentType = "observer" // no special mode
26 )
27
28 // Agent is a registered agent.
29 type Agent struct {
30 Nick string `json:"nick"`
31 Type AgentType `json:"type"`
32 Channels []string `json:"channels"`
33 Permissions []string `json:"permissions"`
34 CreatedAt time.Time `json:"created_at"`
35 Revoked bool `json:"revoked"`
 
36 }
37
38 // Credentials are the SASL credentials an agent uses to connect to Ergo.
39 type Credentials struct {
40 Nick string `json:"nick"`
41 Passphrase string `json:"passphrase"`
42 }
43
44 // EngagementPayload is the signed payload delivered to an agent on registration.
45 // It describes the agent's channel assignments, permissions, and engagement rules.
46 type EngagementPayload struct {
47 V int `json:"v"`
48 Nick string `json:"nick"`
49 Type AgentType `json:"type"`
50 Channels []string `json:"channels"`
51 Permissions []string `json:"permissions"`
52 IssuedAt time.Time `json:"issued_at"`
53 }
54
55 // SignedPayload wraps an EngagementPayload with an HMAC signature.
56 type SignedPayload struct {
57 Payload EngagementPayload `json:"payload"`
@@ -82,11 +82,16 @@
82 }
83 }
84
85 // Register creates a new agent, provisions its Ergo account, and returns
86 // credentials and a signed rules-of-engagement payload.
87 func (r *Registry) Register(nick string, agentType AgentType, channels, permissions []string) (*Credentials, *SignedPayload, error) {
 
 
 
 
 
88 r.mu.Lock()
89 defer r.mu.Unlock()
90
91 if existing, ok := r.agents[nick]; ok && !existing.Revoked {
92 return nil, nil, fmt.Errorf("registry: agent %q already registered", nick)
@@ -102,12 +107,13 @@
102 }
103
104 agent := &Agent{
105 Nick: nick,
106 Type: agentType,
107 Channels: channels,
108 Permissions: permissions,
 
109 CreatedAt: time.Now(),
110 }
111 r.agents[nick] = agent
112
113 payload, err := r.signPayload(agent)
@@ -196,16 +202,15 @@
196 return agent, nil
197 }
198
199 func (r *Registry) signPayload(agent *Agent) (*SignedPayload, error) {
200 payload := EngagementPayload{
201 V: 1,
202 Nick: agent.Nick,
203 Type: agent.Type,
204 Channels: agent.Channels,
205 Permissions: agent.Permissions,
206 IssuedAt: time.Now(),
207 }
208
209 data, err := json.Marshal(payload)
210 if err != nil {
211 return nil, err
212
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -25,33 +25,33 @@
25 AgentTypeObserver AgentType = "observer" // no special mode
26 )
27
28 // Agent is a registered agent.
29 type Agent struct {
30 Nick string `json:"nick"`
31 Type AgentType `json:"type"`
32 Channels []string `json:"channels"` // convenience: same as Config.Channels
33 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
34 Config EngagementConfig `json:"config"`
35 CreatedAt time.Time `json:"created_at"`
36 Revoked bool `json:"revoked"`
37 }
38
39 // Credentials are the SASL credentials an agent uses to connect to Ergo.
40 type Credentials struct {
41 Nick string `json:"nick"`
42 Passphrase string `json:"passphrase"`
43 }
44
45 // EngagementPayload is the signed payload delivered to an agent on registration.
46 // Agents verify this with VerifyPayload() before trusting its contents.
47 type EngagementPayload struct {
48 V int `json:"v"`
49 Nick string `json:"nick"`
50 Type AgentType `json:"type"`
51 Config EngagementConfig `json:"config"`
52 IssuedAt time.Time `json:"issued_at"`
 
53 }
54
55 // SignedPayload wraps an EngagementPayload with an HMAC signature.
56 type SignedPayload struct {
57 Payload EngagementPayload `json:"payload"`
@@ -82,11 +82,16 @@
82 }
83 }
84
85 // Register creates a new agent, provisions its Ergo account, and returns
86 // credentials and a signed rules-of-engagement payload.
87 // cfg is validated before any provisioning occurs.
88 func (r *Registry) Register(nick string, agentType AgentType, cfg EngagementConfig) (*Credentials, *SignedPayload, error) {
89 if err := cfg.Validate(); err != nil {
90 return nil, nil, fmt.Errorf("registry: invalid engagement config: %w", err)
91 }
92
93 r.mu.Lock()
94 defer r.mu.Unlock()
95
96 if existing, ok := r.agents[nick]; ok && !existing.Revoked {
97 return nil, nil, fmt.Errorf("registry: agent %q already registered", nick)
@@ -102,12 +107,13 @@
107 }
108
109 agent := &Agent{
110 Nick: nick,
111 Type: agentType,
112 Channels: cfg.Channels,
113 Permissions: cfg.Permissions,
114 Config: cfg,
115 CreatedAt: time.Now(),
116 }
117 r.agents[nick] = agent
118
119 payload, err := r.signPayload(agent)
@@ -196,16 +202,15 @@
202 return agent, nil
203 }
204
205 func (r *Registry) signPayload(agent *Agent) (*SignedPayload, error) {
206 payload := EngagementPayload{
207 V: 1,
208 Nick: agent.Nick,
209 Type: agent.Type,
210 Config: agent.Config,
211 IssuedAt: time.Now(),
 
212 }
213
214 data, err := json.Marshal(payload)
215 if err != nil {
216 return nil, err
217
--- internal/registry/registry_test.go
+++ internal/registry/registry_test.go
@@ -43,17 +43,21 @@
4343
defer m.mu.Unlock()
4444
return m.accounts[nick]
4545
}
4646
4747
var testKey = []byte("test-signing-key-do-not-use-in-production")
48
+
49
+func cfg(channels, permissions []string) registry.EngagementConfig {
50
+ return registry.EngagementConfig{Channels: channels, Permissions: permissions}
51
+}
4852
4953
func TestRegister(t *testing.T) {
5054
p := newMockProvisioner()
5155
r := registry.New(p, testKey)
5256
5357
creds, payload, err := r.Register("claude-01", registry.AgentTypeWorker,
54
- []string{"#fleet", "#project.test"}, []string{"task.create"})
58
+ cfg([]string{"#fleet", "#project.test"}, []string{"task.create"}))
5559
if err != nil {
5660
t.Fatalf("Register: %v", err)
5761
}
5862
5963
if creds.Nick != "claude-01" {
@@ -69,32 +73,32 @@
6973
t.Errorf("payload Nick: got %q", payload.Payload.Nick)
7074
}
7175
if payload.Signature == "" {
7276
t.Error("payload signature is empty")
7377
}
78
+ if len(payload.Payload.Config.Channels) != 2 {
79
+ t.Errorf("payload channels: got %d, want 2", len(payload.Payload.Config.Channels))
80
+ }
7481
}
7582
7683
func TestRegisterDuplicate(t *testing.T) {
7784
p := newMockProvisioner()
7885
r := registry.New(p, testKey)
7986
80
- _, _, err := r.Register("agent-01", registry.AgentTypeWorker, nil, nil)
81
- if err != nil {
87
+ if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
8288
t.Fatalf("first Register: %v", err)
8389
}
84
-
85
- _, _, err = r.Register("agent-01", registry.AgentTypeWorker, nil, nil)
86
- if err == nil {
90
+ if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err == nil {
8791
t.Error("expected error on duplicate registration, got nil")
8892
}
8993
}
9094
9195
func TestRotate(t *testing.T) {
9296
p := newMockProvisioner()
9397
r := registry.New(p, testKey)
9498
95
- creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, nil, nil)
99
+ creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, registry.EngagementConfig{})
96100
if err != nil {
97101
t.Fatalf("Register: %v", err)
98102
}
99103
original := creds.Passphrase
100104
@@ -112,25 +116,22 @@
112116
113117
func TestRevoke(t *testing.T) {
114118
p := newMockProvisioner()
115119
r := registry.New(p, testKey)
116120
117
- creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, nil, nil)
121
+ creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, registry.EngagementConfig{})
118122
if err != nil {
119123
t.Fatalf("Register: %v", err)
120124
}
121125
122126
if err := r.Revoke("agent-03"); err != nil {
123127
t.Fatalf("Revoke: %v", err)
124128
}
125129
126
- // Passphrase in Ergo should have changed to lockout value.
127130
if p.passphrase("agent-03") == creds.Passphrase {
128131
t.Error("passphrase should change after revocation")
129132
}
130
-
131
- // Get should fail for revoked agent.
132133
if _, err := r.Get("agent-03"); err == nil {
133134
t.Error("Get should fail for revoked agent")
134135
}
135136
}
136137
@@ -137,11 +138,11 @@
137138
func TestVerifyPayload(t *testing.T) {
138139
p := newMockProvisioner()
139140
r := registry.New(p, testKey)
140141
141142
_, payload, err := r.Register("agent-04", registry.AgentTypeOrchestrator,
142
- []string{"#fleet"}, []string{"task.create", "task.assign"})
143
+ cfg([]string{"#fleet"}, []string{"task.create", "task.assign"}))
143144
if err != nil {
144145
t.Fatalf("Register: %v", err)
145146
}
146147
147148
if err := registry.VerifyPayload(payload, testKey); err != nil {
@@ -158,11 +159,11 @@
158159
func TestList(t *testing.T) {
159160
p := newMockProvisioner()
160161
r := registry.New(p, testKey)
161162
162163
for _, nick := range []string{"a", "b", "c"} {
163
- if _, _, err := r.Register(nick, registry.AgentTypeWorker, nil, nil); err != nil {
164
+ if _, _, err := r.Register(nick, registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
164165
t.Fatalf("Register %q: %v", nick, err)
165166
}
166167
}
167168
if err := r.Revoke("b"); err != nil {
168169
t.Fatalf("Revoke: %v", err)
@@ -171,5 +172,88 @@
171172
agents := r.List()
172173
if len(agents) != 2 {
173174
t.Errorf("List: got %d agents, want 2 (revoked should be excluded)", len(agents))
174175
}
175176
}
177
+
178
+func TestEngagementConfigValidation(t *testing.T) {
179
+ tests := []struct {
180
+ name string
181
+ cfg registry.EngagementConfig
182
+ wantErr bool
183
+ }{
184
+ {
185
+ name: "valid full config",
186
+ cfg: registry.EngagementConfig{
187
+ Channels: []string{"#fleet", "#project.test"},
188
+ OpsChannels: []string{"#fleet"},
189
+ Permissions: []string{"task.create"},
190
+ RateLimit: registry.RateLimitConfig{MessagesPerSecond: 10, Burst: 20},
191
+ Rules: registry.EngagementRules{
192
+ RespondToTypes: []string{"task.create"},
193
+ IgnoreNicks: []string{"scribe"},
194
+ },
195
+ },
196
+ wantErr: false,
197
+ },
198
+ {
199
+ name: "empty config is valid",
200
+ cfg: registry.EngagementConfig{},
201
+ wantErr: false,
202
+ },
203
+ {
204
+ name: "channel missing hash",
205
+ cfg: registry.EngagementConfig{Channels: []string{"fleet"}},
206
+ wantErr: true,
207
+ },
208
+ {
209
+ name: "channel with space",
210
+ cfg: registry.EngagementConfig{Channels: []string{"#fleet channel"}},
211
+ wantErr: true,
212
+ },
213
+ {
214
+ name: "ops_channel not in channels",
215
+ cfg: registry.EngagementConfig{Channels: []string{"#fleet"}, OpsChannels: []string{"#other"}},
216
+ wantErr: true,
217
+ },
218
+ {
219
+ name: "negative rate limit",
220
+ cfg: registry.EngagementConfig{RateLimit: registry.RateLimitConfig{MessagesPerSecond: -1}},
221
+ wantErr: true,
222
+ },
223
+ {
224
+ name: "negative burst",
225
+ cfg: registry.EngagementConfig{RateLimit: registry.RateLimitConfig{Burst: -5}},
226
+ wantErr: true,
227
+ },
228
+ {
229
+ name: "empty respond_to_type",
230
+ cfg: registry.EngagementConfig{Rules: registry.EngagementRules{RespondToTypes: []string{""}}},
231
+ wantErr: true,
232
+ },
233
+ }
234
+
235
+ for _, tt := range tests {
236
+ t.Run(tt.name, func(t *testing.T) {
237
+ err := tt.cfg.Validate()
238
+ if (err != nil) != tt.wantErr {
239
+ t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
240
+ }
241
+ })
242
+ }
243
+}
244
+
245
+func TestRegisterInvalidConfig(t *testing.T) {
246
+ p := newMockProvisioner()
247
+ r := registry.New(p, testKey)
248
+
249
+ _, _, err := r.Register("bad-agent", registry.AgentTypeWorker, registry.EngagementConfig{
250
+ Channels: []string{"no-hash-here"},
251
+ })
252
+ if err == nil {
253
+ t.Error("expected error for invalid channel name, got nil")
254
+ }
255
+ // Account should not have been created.
256
+ if p.passphrase("bad-agent") != "" {
257
+ t.Error("account should not be created when config is invalid")
258
+ }
259
+}
176260
--- internal/registry/registry_test.go
+++ internal/registry/registry_test.go
@@ -43,17 +43,21 @@
43 defer m.mu.Unlock()
44 return m.accounts[nick]
45 }
46
47 var testKey = []byte("test-signing-key-do-not-use-in-production")
 
 
 
 
48
49 func TestRegister(t *testing.T) {
50 p := newMockProvisioner()
51 r := registry.New(p, testKey)
52
53 creds, payload, err := r.Register("claude-01", registry.AgentTypeWorker,
54 []string{"#fleet", "#project.test"}, []string{"task.create"})
55 if err != nil {
56 t.Fatalf("Register: %v", err)
57 }
58
59 if creds.Nick != "claude-01" {
@@ -69,32 +73,32 @@
69 t.Errorf("payload Nick: got %q", payload.Payload.Nick)
70 }
71 if payload.Signature == "" {
72 t.Error("payload signature is empty")
73 }
 
 
 
74 }
75
76 func TestRegisterDuplicate(t *testing.T) {
77 p := newMockProvisioner()
78 r := registry.New(p, testKey)
79
80 _, _, err := r.Register("agent-01", registry.AgentTypeWorker, nil, nil)
81 if err != nil {
82 t.Fatalf("first Register: %v", err)
83 }
84
85 _, _, err = r.Register("agent-01", registry.AgentTypeWorker, nil, nil)
86 if err == nil {
87 t.Error("expected error on duplicate registration, got nil")
88 }
89 }
90
91 func TestRotate(t *testing.T) {
92 p := newMockProvisioner()
93 r := registry.New(p, testKey)
94
95 creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, nil, nil)
96 if err != nil {
97 t.Fatalf("Register: %v", err)
98 }
99 original := creds.Passphrase
100
@@ -112,25 +116,22 @@
112
113 func TestRevoke(t *testing.T) {
114 p := newMockProvisioner()
115 r := registry.New(p, testKey)
116
117 creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, nil, nil)
118 if err != nil {
119 t.Fatalf("Register: %v", err)
120 }
121
122 if err := r.Revoke("agent-03"); err != nil {
123 t.Fatalf("Revoke: %v", err)
124 }
125
126 // Passphrase in Ergo should have changed to lockout value.
127 if p.passphrase("agent-03") == creds.Passphrase {
128 t.Error("passphrase should change after revocation")
129 }
130
131 // Get should fail for revoked agent.
132 if _, err := r.Get("agent-03"); err == nil {
133 t.Error("Get should fail for revoked agent")
134 }
135 }
136
@@ -137,11 +138,11 @@
137 func TestVerifyPayload(t *testing.T) {
138 p := newMockProvisioner()
139 r := registry.New(p, testKey)
140
141 _, payload, err := r.Register("agent-04", registry.AgentTypeOrchestrator,
142 []string{"#fleet"}, []string{"task.create", "task.assign"})
143 if err != nil {
144 t.Fatalf("Register: %v", err)
145 }
146
147 if err := registry.VerifyPayload(payload, testKey); err != nil {
@@ -158,11 +159,11 @@
158 func TestList(t *testing.T) {
159 p := newMockProvisioner()
160 r := registry.New(p, testKey)
161
162 for _, nick := range []string{"a", "b", "c"} {
163 if _, _, err := r.Register(nick, registry.AgentTypeWorker, nil, nil); err != nil {
164 t.Fatalf("Register %q: %v", nick, err)
165 }
166 }
167 if err := r.Revoke("b"); err != nil {
168 t.Fatalf("Revoke: %v", err)
@@ -171,5 +172,88 @@
171 agents := r.List()
172 if len(agents) != 2 {
173 t.Errorf("List: got %d agents, want 2 (revoked should be excluded)", len(agents))
174 }
175 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
--- internal/registry/registry_test.go
+++ internal/registry/registry_test.go
@@ -43,17 +43,21 @@
43 defer m.mu.Unlock()
44 return m.accounts[nick]
45 }
46
47 var testKey = []byte("test-signing-key-do-not-use-in-production")
48
49 func cfg(channels, permissions []string) registry.EngagementConfig {
50 return registry.EngagementConfig{Channels: channels, Permissions: permissions}
51 }
52
53 func TestRegister(t *testing.T) {
54 p := newMockProvisioner()
55 r := registry.New(p, testKey)
56
57 creds, payload, err := r.Register("claude-01", registry.AgentTypeWorker,
58 cfg([]string{"#fleet", "#project.test"}, []string{"task.create"}))
59 if err != nil {
60 t.Fatalf("Register: %v", err)
61 }
62
63 if creds.Nick != "claude-01" {
@@ -69,32 +73,32 @@
73 t.Errorf("payload Nick: got %q", payload.Payload.Nick)
74 }
75 if payload.Signature == "" {
76 t.Error("payload signature is empty")
77 }
78 if len(payload.Payload.Config.Channels) != 2 {
79 t.Errorf("payload channels: got %d, want 2", len(payload.Payload.Config.Channels))
80 }
81 }
82
83 func TestRegisterDuplicate(t *testing.T) {
84 p := newMockProvisioner()
85 r := registry.New(p, testKey)
86
87 if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
 
88 t.Fatalf("first Register: %v", err)
89 }
90 if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err == nil {
 
 
91 t.Error("expected error on duplicate registration, got nil")
92 }
93 }
94
95 func TestRotate(t *testing.T) {
96 p := newMockProvisioner()
97 r := registry.New(p, testKey)
98
99 creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, registry.EngagementConfig{})
100 if err != nil {
101 t.Fatalf("Register: %v", err)
102 }
103 original := creds.Passphrase
104
@@ -112,25 +116,22 @@
116
117 func TestRevoke(t *testing.T) {
118 p := newMockProvisioner()
119 r := registry.New(p, testKey)
120
121 creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, registry.EngagementConfig{})
122 if err != nil {
123 t.Fatalf("Register: %v", err)
124 }
125
126 if err := r.Revoke("agent-03"); err != nil {
127 t.Fatalf("Revoke: %v", err)
128 }
129
 
130 if p.passphrase("agent-03") == creds.Passphrase {
131 t.Error("passphrase should change after revocation")
132 }
 
 
133 if _, err := r.Get("agent-03"); err == nil {
134 t.Error("Get should fail for revoked agent")
135 }
136 }
137
@@ -137,11 +138,11 @@
138 func TestVerifyPayload(t *testing.T) {
139 p := newMockProvisioner()
140 r := registry.New(p, testKey)
141
142 _, payload, err := r.Register("agent-04", registry.AgentTypeOrchestrator,
143 cfg([]string{"#fleet"}, []string{"task.create", "task.assign"}))
144 if err != nil {
145 t.Fatalf("Register: %v", err)
146 }
147
148 if err := registry.VerifyPayload(payload, testKey); err != nil {
@@ -158,11 +159,11 @@
159 func TestList(t *testing.T) {
160 p := newMockProvisioner()
161 r := registry.New(p, testKey)
162
163 for _, nick := range []string{"a", "b", "c"} {
164 if _, _, err := r.Register(nick, registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
165 t.Fatalf("Register %q: %v", nick, err)
166 }
167 }
168 if err := r.Revoke("b"); err != nil {
169 t.Fatalf("Revoke: %v", err)
@@ -171,5 +172,88 @@
172 agents := r.List()
173 if len(agents) != 2 {
174 t.Errorf("List: got %d agents, want 2 (revoked should be excluded)", len(agents))
175 }
176 }
177
178 func TestEngagementConfigValidation(t *testing.T) {
179 tests := []struct {
180 name string
181 cfg registry.EngagementConfig
182 wantErr bool
183 }{
184 {
185 name: "valid full config",
186 cfg: registry.EngagementConfig{
187 Channels: []string{"#fleet", "#project.test"},
188 OpsChannels: []string{"#fleet"},
189 Permissions: []string{"task.create"},
190 RateLimit: registry.RateLimitConfig{MessagesPerSecond: 10, Burst: 20},
191 Rules: registry.EngagementRules{
192 RespondToTypes: []string{"task.create"},
193 IgnoreNicks: []string{"scribe"},
194 },
195 },
196 wantErr: false,
197 },
198 {
199 name: "empty config is valid",
200 cfg: registry.EngagementConfig{},
201 wantErr: false,
202 },
203 {
204 name: "channel missing hash",
205 cfg: registry.EngagementConfig{Channels: []string{"fleet"}},
206 wantErr: true,
207 },
208 {
209 name: "channel with space",
210 cfg: registry.EngagementConfig{Channels: []string{"#fleet channel"}},
211 wantErr: true,
212 },
213 {
214 name: "ops_channel not in channels",
215 cfg: registry.EngagementConfig{Channels: []string{"#fleet"}, OpsChannels: []string{"#other"}},
216 wantErr: true,
217 },
218 {
219 name: "negative rate limit",
220 cfg: registry.EngagementConfig{RateLimit: registry.RateLimitConfig{MessagesPerSecond: -1}},
221 wantErr: true,
222 },
223 {
224 name: "negative burst",
225 cfg: registry.EngagementConfig{RateLimit: registry.RateLimitConfig{Burst: -5}},
226 wantErr: true,
227 },
228 {
229 name: "empty respond_to_type",
230 cfg: registry.EngagementConfig{Rules: registry.EngagementRules{RespondToTypes: []string{""}}},
231 wantErr: true,
232 },
233 }
234
235 for _, tt := range tests {
236 t.Run(tt.name, func(t *testing.T) {
237 err := tt.cfg.Validate()
238 if (err != nil) != tt.wantErr {
239 t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
240 }
241 })
242 }
243 }
244
245 func TestRegisterInvalidConfig(t *testing.T) {
246 p := newMockProvisioner()
247 r := registry.New(p, testKey)
248
249 _, _, err := r.Register("bad-agent", registry.AgentTypeWorker, registry.EngagementConfig{
250 Channels: []string{"no-hash-here"},
251 })
252 if err == nil {
253 t.Error("expected error for invalid channel name, got nil")
254 }
255 // Account should not have been created.
256 if p.passphrase("bad-agent") != "" {
257 t.Error("account should not be created when config is invalid")
258 }
259 }
260

Keyboard Shortcuts

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