diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 40fb3bc3..925092e1 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -199,6 +199,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents RunID: runID, Stream: enableStream, HistoryLimit: msg.HistoryLimit, + ToolAllow: msg.ToolAllow, ExtraSystemPrompt: extraPrompt, SkillFilter: skillFilter, }, scheduler.ScheduleOpts{ diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 552feaf2..165b9b86 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -254,6 +254,7 @@ type RunRequest struct { ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) + ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") LocalKey string // composite key with topic/thread suffix for routing (e.g. "-100123:topic:42") ParentTraceID uuid.UUID // if set, reuse parent trace instead of creating new (announce runs) ParentRootSpanID uuid.UUID // if set, nest announce agent span under this parent span @@ -573,8 +574,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Build provider request with policy-filtered tools var toolDefs []providers.ToolDefinition + var allowedTools map[string]bool if l.toolPolicy != nil { - toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, nil, false, false) + toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), l.agentToolPolicy, req.ToolAllow, false, false) + allowedTools = make(map[string]bool, len(toolDefs)) + for _, td := range toolDefs { + allowedTools[td.Function.Name] = true + } } else { toolDefs = l.tools.ProviderDefs() } @@ -703,7 +709,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) argsHash := loopDetector.record(tc.Name, tc.Arguments) toolSpanStart := time.Now().UTC() - result := l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) + var result *tools.Result + if allowedTools != nil && !allowedTools[tc.Name] { + slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name) + result = tools.ErrorResult("tool not allowed by policy: " + tc.Name) + } else { + result = l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) + } l.emitToolSpan(ctx, toolSpanStart, tc.Name, tc.ID, string(argsJSON), result) @@ -796,7 +808,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) argsJSON, _ := json.Marshal(tc.Arguments) slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON), "parallel", true) spanStart := time.Now().UTC() - result := l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) + var result *tools.Result + if allowedTools != nil && !allowedTools[tc.Name] { + slog.Warn("security.tool_policy_blocked", "agent", l.id, "tool", tc.Name) + result = tools.ErrorResult("tool not allowed by policy: " + tc.Name) + } else { + result = l.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, req.Channel, req.ChatID, req.PeerKind, req.SessionKey, nil) + } resultCh <- indexedResult{idx: idx, tc: tc, result: result, argsJSON: string(argsJSON), spanStart: spanStart} }(i, tc) } diff --git a/internal/bus/types.go b/internal/bus/types.go index d524797d..93ad2429 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -14,6 +14,7 @@ type InboundMessage struct { AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing) UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap) HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config) + ToolAllow []string `json:"tool_allow,omitempty"` // per-group tool allow list (nil = no restriction) Metadata map[string]string `json:"metadata,omitempty"` } diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index b837ab9f..262ad134 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -441,6 +441,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { UserID: userID, AgentID: targetAgentID, HistoryLimit: c.historyLimit, + ToolAllow: topicCfg.tools, Metadata: metadata, }) diff --git a/internal/channels/telegram/topic_config.go b/internal/channels/telegram/topic_config.go index 1673baff..93ac9608 100644 --- a/internal/channels/telegram/topic_config.go +++ b/internal/channels/telegram/topic_config.go @@ -16,6 +16,7 @@ type resolvedTopicConfig struct { allowFrom []string enabled *bool skills []string // nil = inherit, non-nil = override (empty = no skills) + tools []string // nil = inherit (all tools), non-nil = override (supports "group:xxx") systemPrompt string // concatenated group + topic prompts } @@ -73,6 +74,9 @@ func mergeGroupInto(dst *resolvedTopicConfig, src *config.TelegramGroupConfig) { if src.Skills != nil { dst.skills = src.Skills } + if src.Tools != nil { + dst.tools = src.Tools + } if src.SystemPrompt != "" { dst.systemPrompt = src.SystemPrompt } @@ -97,6 +101,10 @@ func mergeTopicInto(dst *resolvedTopicConfig, src *config.TelegramTopicConfig, g if src.Skills != nil { dst.skills = src.Skills } + // Tools: topic overrides group (same firstDefined pattern as skills). + if src.Tools != nil { + dst.tools = src.Tools + } // SystemPrompt: concatenate group + topic (both may exist, matching TS). if src.SystemPrompt != "" { parts := []string{} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 2f82670e..1c950566 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -48,6 +48,7 @@ type TelegramGroupConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"` // override allow_from for this group Enabled *bool `json:"enabled,omitempty"` // disable bot for this group (default: true) Skills []string `json:"skills,omitempty"` // skill whitelist (nil = all, [] = none) + Tools []string `json:"tools,omitempty"` // tool allow list (nil = all, supports "group:xxx") SystemPrompt string `json:"system_prompt,omitempty"` // extra system prompt for this group Topics map[string]*TelegramTopicConfig `json:"topics,omitempty"` // per-topic overrides (key: thread ID string) } @@ -58,6 +59,7 @@ type TelegramTopicConfig struct { RequireMention *bool `json:"require_mention,omitempty"` GroupPolicy string `json:"group_policy,omitempty"` Skills []string `json:"skills,omitempty"` + Tools []string `json:"tools,omitempty"` // tool allow list (nil = inherit, supports "group:xxx") Enabled *bool `json:"enabled,omitempty"` AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` diff --git a/ui/web/src/pages/channels/telegram-group-fields.tsx b/ui/web/src/pages/channels/telegram-group-fields.tsx index 7ed8ac7c..237b59ab 100644 --- a/ui/web/src/pages/channels/telegram-group-fields.tsx +++ b/ui/web/src/pages/channels/telegram-group-fields.tsx @@ -8,6 +8,7 @@ import { SelectValue, } from "@/components/ui/select"; import { groupPolicyOptions } from "./channel-schemas"; +import { ToolNameSelect } from "@/components/shared/tool-name-select"; export interface TelegramGroupConfigValues { group_policy?: string; @@ -15,6 +16,7 @@ export interface TelegramGroupConfigValues { enabled?: boolean; allow_from?: string[]; skills?: string[]; + tools?: string[]; system_prompt?: string; } @@ -141,6 +143,18 @@ export function TelegramGroupFields({ config, onChange, idPrefix }: Props) { /> +
+ Restrict which tools are available in this group. Supports group names like "group:web", "group:fs". Empty = inherit all tools. +
+