fix(message): gate local media attachments

This commit is contained in:
Anton Bogdanovich 2026-05-22 16:20:59 -07:00
parent 1bf0d898de
commit ceebda35ee
7 changed files with 178 additions and 76 deletions

View file

@ -400,6 +400,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|------------|------|---------|-------------| |------------|------|---------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | | `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | | `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
| `tools.message.media_enabled` | bool | `false` | Allows the `message` tool to attach local media files by path. This is separate from `tools.send_file.enabled`; enable it only when unified text/media/caption delivery is intended. |
### Read File Mode ### Read File Mode

View file

@ -161,19 +161,19 @@ func registerSharedTools(
// Message tool // Message tool
if cfg.Tools.IsToolEnabled("message") { if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool() messageTool := tools.NewMessageTool()
messageTool.ConfigureLocalMedia( if cfg.Tools.Message.MediaEnabled {
agent.Workspace, messageTool.ConfigureLocalMedia(
cfg.Agents.Defaults.RestrictToWorkspace, agent.Workspace,
cfg.Agents.Defaults.GetMaxMediaSize(), cfg.Agents.Defaults.RestrictToWorkspace,
allowReadPaths, cfg.Agents.Defaults.GetMaxMediaSize(),
) allowReadPaths,
)
}
messageTool.SetSendCallback(func( messageTool.SetSendCallback(func(
ctx context.Context, ctx context.Context,
channel, chatID, content, replyToMessageID string, channel, chatID, content, replyToMessageID string,
mediaParts []bus.MediaPart, mediaParts []bus.MediaPart,
) error { ) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID)
outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata(
tools.ToolAgentID(ctx), tools.ToolAgentID(ctx),
@ -181,22 +181,38 @@ func registerSharedTools(
tools.ToolSessionScope(ctx), tools.ToolSessionScope(ctx),
) )
if len(mediaParts) > 0 { if len(mediaParts) > 0 {
return msgBus.PublishOutboundMedia(pubCtx, bus.OutboundMediaMessage{ outboundMedia := bus.OutboundMediaMessage{
Channel: channel,
ChatID: chatID,
Context: outboundCtx, Context: outboundCtx,
AgentID: outboundAgentID, AgentID: outboundAgentID,
SessionKey: outboundSessionKey, SessionKey: outboundSessionKey,
Scope: outboundScope, Scope: outboundScope,
Parts: mediaParts, Parts: mediaParts,
}) }
if al.channelManager != nil && channel != "" {
return al.channelManager.SendMedia(ctx, outboundMedia)
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutboundMedia(pubCtx, outboundMedia)
} }
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ outboundMessage := bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Context: outboundCtx, Context: outboundCtx,
AgentID: outboundAgentID, AgentID: outboundAgentID,
SessionKey: outboundSessionKey, SessionKey: outboundSessionKey,
Scope: outboundScope, Scope: outboundScope,
Content: content, Content: content,
ReplyToMessageID: replyToMessageID, ReplyToMessageID: replyToMessageID,
}) }
if al.channelManager != nil && channel != "" {
return al.channelManager.SendMessage(ctx, outboundMessage)
}
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutbound(pubCtx, outboundMessage)
}) })
agent.Tools.Register(messageTool) agent.Tools.Register(messageTool)
} }

View file

@ -814,6 +814,12 @@ type ToolConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"` Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"`
} }
type MessageToolsConfig struct {
ToolConfig `yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
MediaEnabled bool `json:"media_enabled" yaml:"-" env:"PICOCLAW_TOOLS_MESSAGE_MEDIA_ENABLED"`
}
type BraveConfig struct { type BraveConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
@ -1026,7 +1032,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
LoadImage ToolConfig `json:"load_image" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LOAD_IMAGE_"` LoadImage ToolConfig `json:"load_image" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LOAD_IMAGE_"`
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` Message MessageToolsConfig `json:"message" yaml:"-"`
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"` Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"`
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`

View file

@ -1480,6 +1480,16 @@ func TestLoadConfig_LoadImageCanBeDisabled(t *testing.T) {
} }
} }
func TestDefaultConfig_MessageMediaDisabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Message.Enabled {
t.Fatal("DefaultConfig().Tools.Message.Enabled should be true")
}
if cfg.Tools.Message.MediaEnabled {
t.Fatal("DefaultConfig().Tools.Message.MediaEnabled should be false")
}
}
func TestToolsConfig_GetFilterMinLength(t *testing.T) { func TestToolsConfig_GetFilterMinLength(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -447,8 +447,11 @@ func DefaultConfig() *Config {
LoadImage: ToolConfig{ LoadImage: ToolConfig{
Enabled: true, Enabled: true,
}, },
Message: ToolConfig{ Message: MessageToolsConfig{
Enabled: true, ToolConfig: ToolConfig{
Enabled: true,
},
MediaEnabled: false,
}, },
ReadFile: ReadFileToolConfig{ ReadFile: ReadFileToolConfig{
Enabled: true, Enabled: true,

View file

@ -37,14 +37,15 @@ type sentTarget struct {
} }
type MessageTool struct { type MessageTool struct {
sendCallback SendCallbackWithContext sendCallback SendCallbackWithContext
workspace string workspace string
restrict bool restrict bool
maxFileSize int maxFileSize int
mediaStore media.MediaStore mediaStore media.MediaStore
allowPaths []*regexp.Regexp allowPaths []*regexp.Regexp
mu sync.Mutex localMediaEnabled bool
sentTargets map[string][]sentTarget mu sync.Mutex
sentTargets map[string][]sentTarget
} }
func NewMessageTool() *MessageTool { func NewMessageTool() *MessageTool {
@ -58,57 +59,66 @@ func (t *MessageTool) Name() string {
} }
func (t *MessageTool) Description() string { func (t *MessageTool) Description() string {
if !t.localMediaEnabled {
return "Send a text message to the user on a chat channel."
}
return "Send a message to the user on a chat channel. Supports text-only, media-only, or text with media attachments." return "Send a message to the user on a chat channel. Supports text-only, media-only, or text with media attachments."
} }
func (t *MessageTool) Parameters() map[string]any { func (t *MessageTool) Parameters() map[string]any {
return map[string]any{ properties := map[string]any{
"type": "object", "content": map[string]any{
"properties": map[string]any{ "type": "string",
"content": map[string]any{ "description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
"type": "string",
"description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
},
"media": map[string]any{
"type": "array",
"description": "Optional local media attachments to send with the message.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the local file. Relative paths are resolved from workspace.",
},
"type": map[string]any{
"type": "string",
"description": "Optional media type hint: image, audio, video, or file.",
},
"filename": map[string]any{
"type": "string",
"description": "Optional display filename. Defaults to the basename of path.",
},
},
"required": []string{"path"},
},
},
"channel": map[string]any{
"type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)",
},
"chat_id": map[string]any{
"type": "string",
"description": "Optional: target chat/user ID",
},
"reply_to_message_id": map[string]any{
"type": "string",
"description": "Optional: reply target message ID for channels that support threaded replies",
},
}, },
"anyOf": []map[string]any{ "channel": map[string]any{
{"required": []string{"content"}}, "type": "string",
{"required": []string{"media"}}, "description": "Optional: target channel (telegram, whatsapp, etc.)",
},
"chat_id": map[string]any{
"type": "string",
"description": "Optional: target chat/user ID",
},
"reply_to_message_id": map[string]any{
"type": "string",
"description": "Optional: reply target message ID for channels that support threaded replies",
}, },
} }
params := map[string]any{
"type": "object",
"properties": properties,
"required": []string{"content"},
}
if t.localMediaEnabled {
properties["media"] = map[string]any{
"type": "array",
"description": "Optional local media attachments to send with the message. Requires tools.message.media_enabled.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the local file. Relative paths are resolved from workspace.",
},
"type": map[string]any{
"type": "string",
"description": "Optional media type hint: image, audio, video, or file.",
},
"filename": map[string]any{
"type": "string",
"description": "Optional display filename. Defaults to the basename of path.",
},
},
"required": []string{"path"},
},
}
delete(params, "required")
params["anyOf"] = []map[string]any{
{"required": []string{"content"}},
{"required": []string{"media"}},
}
}
return params
} }
func (t *MessageTool) ConfigureLocalMedia( func (t *MessageTool) ConfigureLocalMedia(
@ -124,6 +134,7 @@ func (t *MessageTool) ConfigureLocalMedia(
} }
t.maxFileSize = maxFileSize t.maxFileSize = maxFileSize
t.allowPaths = allowPaths t.allowPaths = allowPaths
t.localMediaEnabled = true
} }
func (t *MessageTool) SetMediaStore(store media.MediaStore) { func (t *MessageTool) SetMediaStore(store media.MediaStore) {
@ -173,6 +184,12 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
if err != nil { if err != nil {
return &ToolResult{ForLLM: err.Error(), IsError: true} return &ToolResult{ForLLM: err.Error(), IsError: true}
} }
if len(mediaArgs) > 0 && !t.localMediaEnabled {
return &ToolResult{
ForLLM: "message media attachments are disabled; enable tools.message.media_enabled to send local media through message",
IsError: true,
}
}
if content == "" && len(mediaArgs) == 0 { if content == "" && len(mediaArgs) == 0 {
return &ToolResult{ForLLM: "content or media is required", IsError: true} return &ToolResult{ForLLM: "content or media is required", IsError: true}
} }
@ -262,6 +279,9 @@ func (t *MessageTool) buildMediaParts(
if len(mediaArgs) == 0 { if len(mediaArgs) == 0 {
return nil, nil return nil, nil
} }
if !t.localMediaEnabled {
return nil, fmt.Errorf("message media attachments are disabled")
}
if t.mediaStore == nil { if t.mediaStore == nil {
return nil, fmt.Errorf("media store not configured") return nil, fmt.Errorf("media store not configured")
} }

View file

@ -250,9 +250,9 @@ func TestMessageTool_Parameters(t *testing.T) {
} }
// Check required properties // Check required properties
anyOf, ok := params["anyOf"].([]map[string]any) required, ok := params["required"].([]string)
if !ok || len(anyOf) != 2 { if !ok || len(required) != 1 || required[0] != "content" {
t.Fatal("Expected anyOf content/media requirement") t.Fatal("Expected content-only required schema when local media is disabled")
} }
// Check content property // Check content property
@ -264,12 +264,8 @@ func TestMessageTool_Parameters(t *testing.T) {
t.Error("Expected content type to be 'string'") t.Error("Expected content type to be 'string'")
} }
mediaProp, ok := props["media"].(map[string]any) if _, hasMedia := props["media"]; hasMedia {
if !ok { t.Fatal("did not expect 'media' property when local media is disabled")
t.Fatal("Expected 'media' property")
}
if mediaProp["type"] != "array" {
t.Error("Expected media type to be 'array'")
} }
// Check channel property (optional) // Check channel property (optional)
@ -300,6 +296,56 @@ func TestMessageTool_Parameters(t *testing.T) {
} }
} }
func TestMessageTool_Parameters_WithLocalMediaEnabled(t *testing.T) {
tool := NewMessageTool()
tool.ConfigureLocalMedia(t.TempDir(), true, 1024*1024, nil)
params := tool.Parameters()
props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("Expected properties to be a map")
}
mediaProp, ok := props["media"].(map[string]any)
if !ok {
t.Fatal("Expected 'media' property")
}
if mediaProp["type"] != "array" {
t.Error("Expected media type to be 'array'")
}
anyOf, ok := params["anyOf"].([]map[string]any)
if !ok || len(anyOf) != 2 {
t.Fatal("Expected anyOf content/media requirement")
}
if _, ok := params["required"]; ok {
t.Fatal("did not expect top-level required content when media is enabled")
}
}
func TestMessageTool_Execute_WithMediaDisabled(t *testing.T) {
tool := NewMessageTool()
tool.SetSendCallback(func(
ctx context.Context,
channel, chatID, content, replyToMessageID string,
mediaParts []bus.MediaPart,
) error {
t.Fatal("send callback should not run when message media is disabled")
return nil
})
ctx := WithToolContext(context.Background(), "telegram", "-1001")
result := tool.Execute(ctx, map[string]any{
"media": []any{
map[string]any{"path": "photo.jpg"},
},
})
if !result.IsError {
t.Fatal("expected error when message media is disabled")
}
if result.ForLLM != "message media attachments are disabled; enable tools.message.media_enabled to send local media through message" {
t.Fatalf("unexpected error: %q", result.ForLLM)
}
}
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) { func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
tool := NewMessageTool() tool := NewMessageTool()