fix(mail): validate recipient exists before sending (#886)

Add validateRecipient() to check that mail recipients correspond to
existing agents before sending. This prevents mail from being stored
with invalid assignees that won't match inbox queries.

The validation queries agent beads and checks if any match the
recipient identity. The only special case is "overseer" which is the
human operator and doesn't have an agent bead.

Tests create a temporary isolated beads database with test agents
to validate both success and failure cases. Tests are skipped if
bd CLI is not available (e.g., in CI).

Fixes gt-0y8qa

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
aleiby
2026-01-24 21:46:09 -08:00
committed by GitHub
parent 2fb787c7a2
commit 4e4824a6c6
2 changed files with 133 additions and 0 deletions

View File

@@ -566,11 +566,39 @@ func (r *Router) sendToGroup(msg *Message) error {
return nil
}
// validateRecipient checks that the recipient identity corresponds to an existing agent.
// Returns an error if the recipient is invalid or doesn't exist.
func (r *Router) validateRecipient(identity string) error {
// Overseer is the human operator, not an agent bead
if identity == "overseer" {
return nil
}
// Query all agents and check if any match this identity
agents, err := r.queryAgents("")
if err != nil {
return fmt.Errorf("failed to query agents: %w", err)
}
for _, agent := range agents {
if agentBeadToAddress(agent) == identity {
return nil // Found matching agent
}
}
return fmt.Errorf("no agent found")
}
// sendToSingle sends a message to a single recipient.
func (r *Router) sendToSingle(msg *Message) error {
// Convert addresses to beads identities
toIdentity := AddressToIdentity(msg.To)
// Validate recipient exists
if err := r.validateRecipient(toIdentity); err != nil {
return fmt.Errorf("invalid recipient %q: %w", msg.To, err)
}
// Build labels for from/thread/reply-to/cc
var labels []string
labels = append(labels, "from:"+msg.From)

View File

@@ -2,6 +2,7 @@ package mail
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
@@ -813,3 +814,107 @@ func TestExpandAnnounceNoTownRoot(t *testing.T) {
t.Errorf("expandAnnounce error = %v, want containing 'no town root'", err)
}
}
// ============ Recipient Validation Tests ============
func TestValidateRecipient(t *testing.T) {
// Skip if bd CLI is not available (e.g., in CI environment)
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd CLI not available, skipping test")
}
// Create isolated beads environment for testing
tmpDir := t.TempDir()
townRoot := tmpDir
// Create .beads directory and initialize
beadsDir := filepath.Join(townRoot, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("creating beads dir: %v", err)
}
// Initialize beads database with "gt" prefix (matches agent bead IDs)
cmd := exec.Command("bd", "init", "gt")
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd init failed: %v\n%s", err, out)
}
// Set issue prefix to "gt" (matches agent bead ID pattern)
cmd = exec.Command("bd", "config", "set", "issue_prefix", "gt")
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd config set issue_prefix failed: %v\n%s", err, out)
}
// Register custom types (agent, message, etc.) - required before creating agents
cmd = exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,slot,queue,event,message,molecule,gate,merge-request")
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd config set types.custom failed: %v\n%s", err, out)
}
// Create test agent beads
createAgent := func(id, title string) {
cmd := exec.Command("bd", "create", title, "--type=agent", "--id="+id, "--force")
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("creating agent %s: %v\n%s", id, err, out)
}
}
// Create agents that match expected bead ID patterns
createAgent("gt-mayor", "Mayor agent")
createAgent("gt-deacon", "Deacon agent")
createAgent("gt-testrig-witness", "Test witness")
createAgent("gt-testrig-crew-alice", "Test crew alice")
createAgent("gt-testrig-polecat-bob", "Test polecat bob")
r := NewRouterWithTownRoot(townRoot, townRoot)
tests := []struct {
name string
identity string
wantErr bool
errMsg string
}{
// Overseer is always valid (human operator, no agent bead)
{"overseer", "overseer", false, ""},
// Town-level agents (validated against beads)
{"mayor", "mayor/", false, ""},
{"deacon", "deacon/", false, ""},
// Rig-level agents (validated against beads)
{"witness", "testrig/witness", false, ""},
{"crew member", "testrig/alice", false, ""},
{"polecat", "testrig/bob", false, ""},
// Invalid addresses - should fail
{"bare name", "ruby", true, "no agent found"},
{"nonexistent rig agent", "testrig/nonexistent", true, "no agent found"},
{"wrong rig", "wrongrig/alice", true, "no agent found"},
{"misrouted town agent", "testrig/mayor", true, "no agent found"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := r.validateRecipient(tt.identity)
if tt.wantErr {
if err == nil {
t.Errorf("validateRecipient(%q) expected error, got nil", tt.identity)
} else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
t.Errorf("validateRecipient(%q) error = %v, want containing %q", tt.identity, err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateRecipient(%q) unexpected error: %v", tt.identity, err)
}
}
})
}
}