fix(message): strip embedded MEDIA: paths and add cross-tenant channel validation

Message tool sent raw MEDIA: paths to channels when LLMs embedded them
in multi-line text instead of using standalone prefix. Also added tenant
validation to prevent agents from sending to channels belonging to other
tenants.

- Extract embedded MEDIA: tokens from message text, resolve as media
  attachments, strip from visible content (keeps surrounding text)
- Add ChannelTenantChecker callback to validate channel belongs to
  calling agent's tenant before any send path
- Add ChannelTenantID() to channel Manager for tenant lookup
- Fix sendMedia() missing ContentType on MediaAttachment
- Fix .docx/.xlsx MIME types to proper OpenXML values
This commit is contained in:
viettranx committed 2026-03-25 13:23:46 +07:00
1 parent 4c987aff69
commit cfb587e171
5 files changed
+396 -8

No files matched your search

+4 -1
View File
@@ -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 {
+18
View File
@@ -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()
+147 -7
View File
@@ -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)
+215
View File
@@ -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)
}
})
}
+12
View File
@@ -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 {