Skip to content

Commit 09d8660

Browse files
fix(mcp): derive claude_cli bridge tool surface from agent policy (#1377)
The bridge exposed a static BridgeToolNames subset that drifted from the tool registry: use_skill, datetime, knowledge_graph_search and skill_manage were never added, while the system prompt's skill-loading protocol requires agents to call use_skill. claude_cli agents following the protocol hit a nonexistent tool and could fabricate results. Implement the structural fix recommended in #1373 triage: - register the full bridge-capable surface (registry minus hard exclusions spawn/create_forum_topic) instead of the static list - gate BOTH tools/list (new WithToolFilter) and tools/call through one shared predicate bridgeToolAllowed: * callers WITH a verified agent policy get exactly the policy-filtered surface (same WouldAllow check the call path always enforced) * callers WITHOUT one (anonymous, or agent without tools_config) keep the legacy conservative BridgeToolNames set - no exposure widening - downgrade the per-call denial log Warn->Info; list filtering makes probes of denied tools rare and the call gate is the intended enforcement point Fixes #1373
1 parent 7844df7 commit 09d8660

2 files changed

Lines changed: 223 additions & 16 deletions

File tree

internal/mcp/bridge_server.go

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2227
var 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"
73134
func 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).
112177
func 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()

internal/mcp/bridge_server_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
mcpgo "github.com/mark3labs/mcp-go/mcp"
8+
9+
"github.com/nextlevelbuilder/goclaw/internal/config"
10+
"github.com/nextlevelbuilder/goclaw/internal/tools"
11+
)
12+
13+
// fakeBridgeTool is a minimal tools.Tool for registry-driven bridge tests.
14+
type fakeBridgeTool struct{ name string }
15+
16+
func (f *fakeBridgeTool) Name() string { return f.name }
17+
func (f *fakeBridgeTool) Description() string { return "fake " + f.name }
18+
func (f *fakeBridgeTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
19+
func (f *fakeBridgeTool) Execute(context.Context, map[string]any) *tools.Result {
20+
return &tools.Result{ForLLM: "ok"}
21+
}
22+
23+
func bridgeTestRegistry(names ...string) *tools.Registry {
24+
reg := tools.NewRegistry()
25+
for _, n := range names {
26+
reg.Register(&fakeBridgeTool{name: n})
27+
}
28+
return reg
29+
}
30+
31+
// Without an agent policy in ctx (anonymous caller, or agent without a
32+
// tools_config), the bridge must fall back to the conservative legacy set —
33+
// deriving from a nil policy would WIDEN exposure for unauthenticated callers.
34+
func TestBridgeToolAllowed_NoAgentPolicy_FallsBackToLegacySet(t *testing.T) {
35+
reg := bridgeTestRegistry("read_file", "use_skill")
36+
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
37+
ctx := context.Background()
38+
39+
if !bridgeToolAllowed(reg, pe, ctx, "read_file") {
40+
t.Error("read_file is in the legacy set — must stay allowed for policy-less callers")
41+
}
42+
if bridgeToolAllowed(reg, pe, ctx, "use_skill") {
43+
t.Error("use_skill is NOT in the legacy set — must stay blocked for policy-less callers")
44+
}
45+
}
46+
47+
// With a verified agent policy in ctx, the bridge derives its tool surface
48+
// from that policy — the fix for the BridgeToolNames drift (#1373): tools the
49+
// agent's policy allows (like use_skill) become callable; tools outside the
50+
// agent's allow list are rejected even if the legacy set contained them.
51+
func TestBridgeToolAllowed_AgentPolicyDerivesSurface(t *testing.T) {
52+
reg := bridgeTestRegistry("read_file", "use_skill", "datetime")
53+
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
54+
ctx := tools.WithToolAgentPolicy(context.Background(),
55+
&config.ToolPolicySpec{Allow: []string{"use_skill", "datetime"}})
56+
57+
if !bridgeToolAllowed(reg, pe, ctx, "use_skill") {
58+
t.Error("use_skill allowed by agent policy — bridge must expose it")
59+
}
60+
if !bridgeToolAllowed(reg, pe, ctx, "datetime") {
61+
t.Error("datetime allowed by agent policy — bridge must expose it")
62+
}
63+
if bridgeToolAllowed(reg, pe, ctx, "read_file") {
64+
t.Error("read_file NOT in the agent allow list — bridge must reject it")
65+
}
66+
}
67+
68+
// Hard-excluded tools can never cross the bridge, regardless of policy.
69+
func TestBridgeToolAllowed_HardExclusionsWinOverPolicy(t *testing.T) {
70+
reg := bridgeTestRegistry("spawn", "create_forum_topic")
71+
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
72+
ctx := tools.WithToolAgentPolicy(context.Background(),
73+
&config.ToolPolicySpec{Allow: []string{"spawn", "create_forum_topic"}})
74+
75+
for _, name := range []string{"spawn", "create_forum_topic"} {
76+
if bridgeToolAllowed(reg, pe, ctx, name) {
77+
t.Errorf("%s is bridge-excluded — policy must not re-enable it", name)
78+
}
79+
}
80+
}
81+
82+
// Nil policy engine (legacy constructor path) keeps the legacy static behavior.
83+
func TestBridgeToolAllowed_NilEngine_LegacyBehavior(t *testing.T) {
84+
reg := bridgeTestRegistry("exec", "use_skill")
85+
ctx := tools.WithToolAgentPolicy(context.Background(),
86+
&config.ToolPolicySpec{Allow: []string{"use_skill"}})
87+
88+
if !bridgeToolAllowed(reg, nil, ctx, "exec") {
89+
t.Error("nil engine: legacy set must apply (exec allowed)")
90+
}
91+
if bridgeToolAllowed(reg, nil, ctx, "use_skill") {
92+
t.Error("nil engine: legacy set must apply (use_skill blocked)")
93+
}
94+
}
95+
96+
// The tools/list filter must show each caller exactly the surface it can call.
97+
func TestBridgeToolFilter_ListsMatchCallableSurface(t *testing.T) {
98+
reg := bridgeTestRegistry("read_file", "use_skill")
99+
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
100+
filter := newBridgeToolFilter(reg, pe)
101+
102+
listed := []mcpgo.Tool{{Name: "read_file"}, {Name: "use_skill"}}
103+
104+
// Anonymous caller → legacy set only.
105+
got := filter(context.Background(), listed)
106+
if len(got) != 1 || got[0].Name != "read_file" {
107+
t.Errorf("anonymous list = %v, want [read_file]", toolNames(got))
108+
}
109+
110+
// Policy'd agent → derived surface.
111+
ctx := tools.WithToolAgentPolicy(context.Background(),
112+
&config.ToolPolicySpec{Allow: []string{"use_skill"}})
113+
got = filter(ctx, listed)
114+
if len(got) != 1 || got[0].Name != "use_skill" {
115+
t.Errorf("policy'd list = %v, want [use_skill]", toolNames(got))
116+
}
117+
}
118+
119+
// Registration must cover the whole registry minus hard exclusions, so a
120+
// policy'd agent can actually call tools beyond the legacy set (the drift bug).
121+
func TestBridgeRegisteredToolNames_RegistryMinusExclusions(t *testing.T) {
122+
reg := bridgeTestRegistry("read_file", "use_skill", "spawn")
123+
names := bridgeRegisteredToolNames(reg)
124+
125+
got := make(map[string]bool, len(names))
126+
for _, n := range names {
127+
got[n] = true
128+
}
129+
if !got["read_file"] || !got["use_skill"] {
130+
t.Errorf("registered = %v, want read_file + use_skill included", names)
131+
}
132+
if got["spawn"] {
133+
t.Errorf("registered = %v, spawn must be excluded", names)
134+
}
135+
}
136+
137+
func toolNames(ts []mcpgo.Tool) []string {
138+
out := make([]string, len(ts))
139+
for i, tt := range ts {
140+
out[i] = tt.Name
141+
}
142+
return out
143+
}

0 commit comments

Comments
 (0)