diff --git a/cmd/gateway.go b/cmd/gateway.go index 5a3cac7e..f69b144f 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -497,11 +497,14 @@ func runGateway() { // Channel manager channelMgr := channels.NewManager(msgBus) - // Wire channel sender on message tool (now that channelMgr exists) + // Wire channel sender + tenant checker on message tool (now that channelMgr exists) if t, ok := toolsReg.Get("message"); ok { if cs, ok := t.(tools.ChannelSenderAware); ok { cs.SetChannelSender(channelMgr.SendToChannel) } + if tc, ok := t.(tools.ChannelTenantCheckerAware); ok { + tc.SetChannelTenantChecker(channelMgr.ChannelTenantID) + } } // Wire group member lister on list_group_members tool if t, ok := toolsReg.Get("list_group_members"); ok { diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 33846bd8..dfc47b91 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -6,6 +6,8 @@ import ( "log/slog" "sync" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -191,6 +193,22 @@ func (m *Manager) ChannelTypeForName(name string) string { return "" } +// ChannelTenantID returns the tenant UUID for a channel instance. +// Zero UUID means legacy/config-based channel (no tenant scope). +// Returns (tenantID, exists). +func (m *Manager) ChannelTenantID(channelName string) (uuid.UUID, bool) { + m.mu.RLock() + ch, ok := m.channels[channelName] + m.mu.RUnlock() + if !ok { + return uuid.Nil, false + } + if tc, ok := ch.(interface{ TenantID() uuid.UUID }); ok { + return tc.TenantID(), true + } + return uuid.Nil, true // legacy channel without tenant scope +} + // ListGroupMembers delegates to the channel's GroupMemberProvider if available. func (m *Manager) ListGroupMembers(ctx context.Context, channelName, chatID string) ([]GroupMember, error) { m.mu.RLock() diff --git a/internal/tools/message.go b/internal/tools/message.go index 51d5bf74..9bd2c899 100644 --- a/internal/tools/message.go +++ b/internal/tools/message.go @@ -4,28 +4,38 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" + "regexp" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" ) +// embeddedMediaPattern matches "MEDIA:" followed by a non-whitespace path. +// Duplicated from agent.mediaPathPattern to avoid tools→agent import cycle. +var embeddedMediaPattern = regexp.MustCompile(`MEDIA:\S+`) + // MessageTool allows the agent to proactively send messages to channels. type MessageTool struct { - workspace string - restrict bool - sender ChannelSender - msgBus *bus.MessageBus + workspace string + restrict bool + sender ChannelSender + msgBus *bus.MessageBus + tenantChecker ChannelTenantChecker } func NewMessageTool(workspace string, restrict bool) *MessageTool { return &MessageTool{workspace: workspace, restrict: restrict} } -func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender = s } -func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } +func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender = s } +func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } +func (t *MessageTool) SetChannelTenantChecker(c ChannelTenantChecker) { t.tenantChecker = c } func (t *MessageTool) Name() string { return "message" } func (t *MessageTool) Description() string { @@ -85,11 +95,35 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result return ErrorResult("target chat ID is required (no current chat in context)") } + // Tenant isolation: validate channel belongs to current tenant. + if err := t.validateChannelTenant(ctx, channel, target); err != nil { + return err + } + // Handle MEDIA: prefix — send file as attachment instead of text. if filePath, ok := t.resolveMediaPath(ctx, message); ok { return t.sendMedia(ctx, channel, target, filePath) } + // Extract embedded MEDIA: paths from multi-line messages. + // LLMs may include MEDIA: in conversational text rather than as a standalone prefix. + message, embeddedMedia := t.extractEmbeddedMedia(ctx, message) + + // If we found embedded media and bus is available, prefer bus path (supports media attachments). + if len(embeddedMedia) > 0 && t.msgBus != nil { + outMsg := bus.OutboundMessage{ + Channel: channel, + ChatID: target, + Content: message, + Media: embeddedMedia, + } + if isGroupContext(ctx) { + outMsg.Metadata = map[string]string{"group_id": target} + } + t.msgBus.PublishOutbound(outMsg) + return SilentResult(fmt.Sprintf(`{"status":"sent","channel":"%s","target":"%s"}`, channel, target)) + } + // Prefer direct channel sender for immediate delivery. // For group chats, fall through to message bus which supports metadata. if t.sender != nil && !isGroupContext(ctx) { @@ -126,6 +160,33 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result return ErrorResult("no channel sender or message bus available") } +// validateChannelTenant checks the target channel belongs to the current tenant. +// Returns an error Result if the send should be blocked, nil if allowed. +func (t *MessageTool) validateChannelTenant(ctx context.Context, channel, target string) *Result { + if t.tenantChecker == nil { + return nil + } + chTenant, chExists := t.tenantChecker(channel) + if !chExists { + return ErrorResult(fmt.Sprintf("channel %q not found", channel)) + } + // Allow: legacy/config-based channels (zero tenant) or master tenant context (system ops). + if chTenant == uuid.Nil { + return nil + } + ctxTenant := store.TenantIDFromContext(ctx) + if ctxTenant == uuid.Nil { + return nil // master tenant / system context + } + if chTenant != ctxTenant { + slog.Warn("security.cross_tenant_send_blocked", + "channel", channel, "target", target, + "ctx_tenant", ctxTenant, "ch_tenant", chTenant) + return ErrorResult("channel not accessible from this tenant") + } + return nil +} + // sendMedia sends a file as a media attachment via the outbound message bus. func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath string) *Result { if _, err := os.Stat(filePath); err != nil { @@ -144,7 +205,7 @@ func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath s t.msgBus.PublishOutbound(bus.OutboundMessage{ Channel: channel, ChatID: target, - Media: []bus.MediaAttachment{{URL: filePath}}, + Media: []bus.MediaAttachment{{URL: filePath, ContentType: mimeFromPath(filePath)}}, Metadata: meta, }) out, _ := json.Marshal(map[string]string{ @@ -156,6 +217,85 @@ func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath s return SilentResult(string(out)) } +// extractEmbeddedMedia scans a multi-line message for embedded MEDIA: path references. +// Returns cleaned text (MEDIA: lines removed) and resolved media attachments. +// Prevents raw MEDIA: paths from leaking to channels when LLMs embed them +// in conversational text instead of using a standalone MEDIA: prefix. +func (t *MessageTool) extractEmbeddedMedia(ctx context.Context, message string) (string, []bus.MediaAttachment) { + if !strings.Contains(message, "MEDIA:") { + return message, nil + } + + lines := strings.Split(message, "\n") + var cleaned []string + var media []bus.MediaAttachment + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Skip [[audio_as_voice]] tags (TTS voice messages). + if strings.HasPrefix(trimmed, "[[audio_as_voice]]") { + continue + } + // Find all MEDIA: tokens on this line. + matches := embeddedMediaPattern.FindAllString(trimmed, -1) + if len(matches) == 0 { + cleaned = append(cleaned, line) + continue + } + // Extract each MEDIA: path and resolve via security-checked path resolution. + for _, raw := range matches { + if resolved, ok := t.resolveMediaPath(ctx, raw); ok { + media = append(media, bus.MediaAttachment{ + URL: resolved, + ContentType: mimeFromPath(resolved), + }) + } + } + // Strip MEDIA: tokens from line, keep surrounding text. + remainder := strings.TrimSpace(embeddedMediaPattern.ReplaceAllString(line, "")) + if remainder != "" { + cleaned = append(cleaned, remainder) + } + } + + return strings.TrimSpace(strings.Join(cleaned, "\n")), media +} + +// mimeFromPath returns a MIME type based on file extension. +// Duplicated from agent.mimeFromExt to avoid tools→agent import cycle. +func mimeFromPath(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".mp4": + return "video/mp4" + case ".ogg", ".opus": + return "audio/ogg" + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/wav" + case ".pdf": + return "application/pdf" + case ".doc": + return "application/msword" + case ".docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case ".xls": + return "application/vnd.ms-excel" + case ".xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + default: + return "application/octet-stream" + } +} + // isGroupContext returns true if the current context indicates a group conversation. func isGroupContext(ctx context.Context) bool { userID := store.UserIDFromContext(ctx) diff --git a/internal/tools/message_test.go b/internal/tools/message_test.go index a7c79b78..61800e72 100644 --- a/internal/tools/message_test.go +++ b/internal/tools/message_test.go @@ -5,6 +5,10 @@ import ( "os" "path/filepath" "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" ) // outsidePath returns an absolute path that is guaranteed to be outside the @@ -144,3 +148,214 @@ func TestIsInTempDir(t *testing.T) { }) } } + +func TestExtractEmbeddedMedia(t *testing.T) { + tmpDir := os.TempDir() + + workspace := t.TempDir() + workspaceCanonical, _ := filepath.EvalSymlinks(workspace) + + // Create test files in workspace. + docsDir := filepath.Join(workspaceCanonical, "docs") + os.MkdirAll(docsDir, 0o755) + reportFile := filepath.Join(docsDir, "report.docx") + os.WriteFile(reportFile, []byte("test"), 0o644) + reportCanonical, _ := filepath.EvalSymlinks(reportFile) + + tool := NewMessageTool(workspaceCanonical, true) + ctx := context.Background() + + t.Run("no MEDIA: in message", func(t *testing.T) { + msg := "Hello, here is your report!" + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + if cleaned != msg { + t.Errorf("expected unchanged message, got %q", cleaned) + } + if len(media) != 0 { + t.Errorf("expected no media, got %d", len(media)) + } + }) + + t.Run("embedded MEDIA: in multi-line message", func(t *testing.T) { + msg := "Here is the file:\nMEDIA:" + reportCanonical + "\nPlease download!" + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "Here is the file:\nPlease download!" { + t.Errorf("unexpected cleaned text: %q", cleaned) + } + if len(media) != 1 { + t.Fatalf("expected 1 media, got %d", len(media)) + } + if media[0].URL != reportCanonical { + t.Errorf("media URL = %q, want %q", media[0].URL, reportCanonical) + } + wantMime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + if media[0].ContentType != wantMime { + t.Errorf("content type = %q, want %q", media[0].ContentType, wantMime) + } + }) + + t.Run("MEDIA: mid-sentence keeps surrounding text", func(t *testing.T) { + msg := "Here is your report MEDIA:" + reportCanonical + " please review" + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "Here is your report please review" { + t.Errorf("surrounding text lost: %q", cleaned) + } + if len(media) != 1 { + t.Fatalf("expected 1 media, got %d", len(media)) + } + }) + + t.Run("multiple MEDIA: on same line", func(t *testing.T) { + img := filepath.Join(tmpDir, "photo.png") + msg := "MEDIA:" + reportCanonical + " MEDIA:" + img + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "" { + t.Errorf("expected empty cleaned text, got %q", cleaned) + } + if len(media) != 2 { + t.Fatalf("expected 2 media from same line, got %d", len(media)) + } + }) + + t.Run("MEDIA: path outside workspace is stripped but no attachment", func(t *testing.T) { + msg := "File:\nMEDIA:" + outsidePath(workspaceCanonical, "etc/passwd") + "\nDone" + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "File:\nDone" { + t.Errorf("MEDIA: line not stripped: %q", cleaned) + } + if len(media) != 0 { + t.Errorf("expected no media for outside-workspace path, got %d", len(media)) + } + }) + + t.Run("message with only MEDIA: lines", func(t *testing.T) { + msg := "MEDIA:" + reportCanonical + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "" { + t.Errorf("expected empty cleaned text, got %q", cleaned) + } + if len(media) != 1 { + t.Fatalf("expected 1 media, got %d", len(media)) + } + }) + + t.Run("audio_as_voice tag stripped", func(t *testing.T) { + msg := "[[audio_as_voice]]\nMEDIA:" + filepath.Join(tmpDir, "voice.ogg") + "\nExtra text" + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "Extra text" { + t.Errorf("unexpected cleaned text: %q", cleaned) + } + if len(media) != 1 { + t.Fatalf("expected 1 media, got %d", len(media)) + } + }) + + t.Run("multiple MEDIA: paths", func(t *testing.T) { + img := filepath.Join(tmpDir, "photo.png") + msg := "Files:\nMEDIA:" + reportCanonical + "\nMEDIA:" + img + "\nEnjoy!" + cleaned, media := tool.extractEmbeddedMedia(ctx, msg) + + if cleaned != "Files:\nEnjoy!" { + t.Errorf("unexpected cleaned text: %q", cleaned) + } + if len(media) != 2 { + t.Fatalf("expected 2 media, got %d", len(media)) + } + }) +} + +func TestMimeFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/tmp/file.png", "image/png"}, + {"/tmp/file.jpg", "image/jpeg"}, + {"/tmp/file.mp4", "video/mp4"}, + {"/tmp/file.ogg", "audio/ogg"}, + {"/tmp/file.pdf", "application/pdf"}, + {"/tmp/file.doc", "application/msword"}, + {"/tmp/file.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + {"/tmp/file.xls", "application/vnd.ms-excel"}, + {"/tmp/file.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + {"/tmp/file.unknown", "application/octet-stream"}, + } + for _, tt := range tests { + t.Run(filepath.Base(tt.path), func(t *testing.T) { + if got := mimeFromPath(tt.path); got != tt.want { + t.Errorf("mimeFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestValidateChannelTenant(t *testing.T) { + tenantA := uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + tenantB := uuid.MustParse("019d1135-7087-7aa9-a2c7-cdaf7af851b1") + + tool := NewMessageTool("", true) + + t.Run("no checker configured allows all", func(t *testing.T) { + ctx := store.WithTenantID(context.Background(), tenantA) + if err := tool.validateChannelTenant(ctx, "telegram", "123"); err != nil { + t.Errorf("expected nil, got error: %s", err.ForLLM) + } + }) + + // Wire a mock checker. + channels := map[string]uuid.UUID{ + "telegram": tenantA, + "tenant-b-tg": tenantB, + } + tool.SetChannelTenantChecker(func(name string) (uuid.UUID, bool) { + tid, ok := channels[name] + return tid, ok + }) + + t.Run("same tenant allows", func(t *testing.T) { + ctx := store.WithTenantID(context.Background(), tenantA) + if err := tool.validateChannelTenant(ctx, "telegram", "123"); err != nil { + t.Errorf("expected nil for same tenant, got: %s", err.ForLLM) + } + }) + + t.Run("cross tenant blocks", func(t *testing.T) { + ctx := store.WithTenantID(context.Background(), tenantA) + err := tool.validateChannelTenant(ctx, "tenant-b-tg", "456") + if err == nil { + t.Fatal("expected error for cross-tenant send, got nil") + } + if !err.IsError { + t.Error("expected IsError=true") + } + }) + + t.Run("channel not found blocks", func(t *testing.T) { + ctx := store.WithTenantID(context.Background(), tenantA) + err := tool.validateChannelTenant(ctx, "nonexistent", "789") + if err == nil { + t.Fatal("expected error for missing channel, got nil") + } + }) + + t.Run("nil channel tenant allows (legacy)", func(t *testing.T) { + channels["legacy-ch"] = uuid.Nil + ctx := store.WithTenantID(context.Background(), tenantA) + if err := tool.validateChannelTenant(ctx, "legacy-ch", "123"); err != nil { + t.Errorf("expected nil for legacy channel, got: %s", err.ForLLM) + } + }) + + t.Run("nil context tenant allows (master/system)", func(t *testing.T) { + ctx := context.Background() // no tenant in context + if err := tool.validateChannelTenant(ctx, "tenant-b-tg", "456"); err != nil { + t.Errorf("expected nil for master context, got: %s", err.ForLLM) + } + }) +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 7c2615d3..88c08894 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -3,6 +3,8 @@ package tools import ( "context" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -99,6 +101,16 @@ type ChannelSenderAware interface { SetChannelSender(ChannelSender) } +// ChannelTenantChecker returns the tenant UUID for a channel instance. +// Used by the message tool to prevent cross-tenant sends. +// Returns (tenantID, exists). Zero tenantID means legacy/config-based channel. +type ChannelTenantChecker func(channelName string) (tenantID uuid.UUID, exists bool) + +// ChannelTenantCheckerAware tools can receive a channel tenant checker. +type ChannelTenantCheckerAware interface { + SetChannelTenantChecker(ChannelTenantChecker) +} + // ChannelAware is optionally implemented by tools that only work on specific channel types. // Tools implementing this are filtered out when the current channel type doesn't match. type ChannelAware interface {