@@ -15,8 +15,13 @@ import (
1515 "github.com/nextlevelbuilder/goclaw/internal/tools"
1616)
1717
18- // BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge.
19- // Excluded: spawn (agent loop), create_forum_topic (channels).
18+ // BridgeToolNames is the LEGACY conservative tool set, kept as the fallback
19+ // surface for callers that carry no verified agent tool policy in context
20+ // (no/invalid X-Agent-ID headers, or an agent without a tools_config). For
21+ // callers WITH an agent policy, the bridge surface is derived from that
22+ // policy instead — see bridgeToolAllowed. This removes the drift where the
23+ // system prompt requires tools (e.g. use_skill) that a static list forgot to
24+ // expose (#1373), without widening exposure for unauthenticated callers.
2025// delegate is included: it self-gates via CanDelegate/agent_links and resolves
2126// its source agent from the X-Agent-ID header context, same as team_tasks.
2227var BridgeToolNames = map [string ]bool {
@@ -54,6 +59,62 @@ var BridgeToolNames = map[string]bool{
5459 "delegate" : true ,
5560}
5661
62+ // bridgeExcludedTools lists tools that cannot operate over the bridge at all,
63+ // regardless of any agent policy: spawn drives the in-process agent loop and
64+ // create_forum_topic needs a live channel connection owned by the gateway.
65+ var bridgeExcludedTools = map [string ]bool {
66+ "spawn" : true ,
67+ "create_forum_topic" : true ,
68+ }
69+
70+ // bridgeRegisteredToolNames returns every registry tool the bridge may ever
71+ // expose: the full registry minus hard exclusions. Which subset a given
72+ // caller can actually list/call is decided per-request by bridgeToolAllowed.
73+ func bridgeRegisteredToolNames (reg * tools.Registry ) []string {
74+ var names []string
75+ for _ , name := range reg .List () {
76+ if bridgeExcludedTools [name ] {
77+ continue
78+ }
79+ names = append (names , name )
80+ }
81+ return names
82+ }
83+
84+ // bridgeToolAllowed is the single predicate for both tools/list filtering and
85+ // tools/call gating:
86+ // - hard-excluded tools are never allowed;
87+ // - callers WITHOUT a verified agent policy (or when no policy engine is
88+ // wired) fall back to the legacy conservative BridgeToolNames set, so
89+ // anonymous exposure is unchanged;
90+ // - callers WITH an agent policy get exactly the policy-filtered surface
91+ // (same WouldAllow predicate the call path always enforced).
92+ func bridgeToolAllowed (reg * tools.Registry , policyEngine * tools.PolicyEngine , ctx context.Context , name string ) bool {
93+ if bridgeExcludedTools [name ] {
94+ return false
95+ }
96+ agentPolicy := tools .ToolAgentPolicyFromCtx (ctx )
97+ if policyEngine == nil || agentPolicy == nil {
98+ return BridgeToolNames [name ]
99+ }
100+ return policyEngine .WouldAllow (reg , name , bridgeProviderName , agentPolicy , nil )
101+ }
102+
103+ // newBridgeToolFilter returns a tools/list filter so each caller only sees
104+ // the tools it can actually call. Without this, the CLI probes tools that the
105+ // call path then denies, wasting agent turns and spamming denial logs.
106+ func newBridgeToolFilter (reg * tools.Registry , policyEngine * tools.PolicyEngine ) mcpserver.ToolFilterFunc {
107+ return func (ctx context.Context , listed []mcpgo.Tool ) []mcpgo.Tool {
108+ filtered := make ([]mcpgo.Tool , 0 , len (listed ))
109+ for _ , tool := range listed {
110+ if bridgeToolAllowed (reg , policyEngine , ctx , tool .Name ) {
111+ filtered = append (filtered , tool )
112+ }
113+ }
114+ return filtered
115+ }
116+ }
117+
57118// bridgeProviderName identifies the caller for per-provider tool policy
58119// overrides (config.ToolPolicySpec.ByProvider) when enforcing bridge access.
59120// All MCP bridge traffic originates from the Claude CLI subprocess.
@@ -73,11 +134,15 @@ const bridgeProviderName = "claude-cli"
73134func NewBridgeServer (reg * tools.Registry , version string , msgBus * bus.MessageBus , policyEngine * tools.PolicyEngine ) * mcpserver.StreamableHTTPServer {
74135 srv := mcpserver .NewMCPServer ("goclaw-bridge" , version ,
75136 mcpserver .WithToolCapabilities (false ),
137+ // Per-caller list filtering: each agent only sees its callable surface.
138+ mcpserver .WithToolFilter (newBridgeToolFilter (reg , policyEngine )),
76139 )
77140
78- // Register each safe tool from the GoClaw registry
141+ // Register the full bridge-capable surface (registry minus hard
142+ // exclusions). Per-caller visibility and callability are both decided by
143+ // bridgeToolAllowed at request time.
79144 var registered int
80- for name := range BridgeToolNames {
145+ for _ , name := range bridgeRegisteredToolNames ( reg ) {
81146 t , ok := reg .Get (name )
82147 if ! ok {
83148 continue
@@ -111,18 +176,17 @@ func convertToMCPTool(t tools.Tool) mcpgo.Tool {
111176// them as outbound media attachments so files reach the user (e.g. Telegram document).
112177func makeToolHandler (reg * tools.Registry , toolName string , msgBus * bus.MessageBus , policyEngine * tools.PolicyEngine ) mcpserver.ToolHandlerFunc {
113178 return func (ctx context.Context , req mcpgo.CallToolRequest ) (* mcpgo.CallToolResult , error ) {
114- // Enforce the calling agent's own tool policy before execution. Static
115- // membership in BridgeToolNames only bounds what the bridge process
116- // COULD ever expose; it does not know which agent is calling. Without
117- // this check, any agent reaching the bridge (e.g. via Claude CLI) can
118- // invoke any bridge tool regardless of its configured tool policy.
119- if policyEngine != nil {
120- agentPolicy := tools .ToolAgentPolicyFromCtx (ctx )
121- if ! policyEngine .WouldAllow (reg , toolName , bridgeProviderName , agentPolicy , nil ) {
122- slog .Warn ("security.mcp_bridge_denied" ,
123- "tool" , toolName , "agent_key" , tools .ToolAgentKeyFromCtx (ctx ))
124- return mcpgo .NewToolResultError ("tool not allowed by policy: " + toolName ), nil
125- }
179+ // Gate execution with the same predicate that filters tools/list, so
180+ // visibility and callability can never drift apart. Registration only
181+ // bounds what the bridge process COULD ever expose; it does not know
182+ // which agent is calling.
183+ // Info (not Warn): the per-call gate is the intended enforcement
184+ // point and the CLI legitimately probes; list filtering already keeps
185+ // this rare.
186+ if ! bridgeToolAllowed (reg , policyEngine , ctx , toolName ) {
187+ slog .Info ("mcp_bridge_denied" ,
188+ "tool" , toolName , "agent_key" , tools .ToolAgentKeyFromCtx (ctx ))
189+ return mcpgo .NewToolResultError ("tool not allowed by policy: " + toolName ), nil
126190 }
127191
128192 args := req .GetArguments ()
0 commit comments