fix(message): gate local media attachments
This commit is contained in:
parent
1bf0d898de
commit
ceebda35ee
7 changed files with 178 additions and 76 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue