diff --git a/cmd/gateway_events.go b/cmd/gateway_events.go index 6569f5f8..932b4e07 100644 --- a/cmd/gateway_events.go +++ b/cmd/gateway_events.go @@ -95,13 +95,20 @@ func (d *gatewayDeps) wireTeamProgressNotifySubscriber() { UserID: meta.UserID, PeerKind: meta.PeerKind, Content: leaderContent, - Metadata: map[string]string{"run_kind": tools.RunKindNotification}, + Metadata: func() map[string]string { + m := map[string]string{"run_kind": tools.RunKindNotification} + if meta.LocalKey != "" { + m["local_key"] = meta.LocalKey + } + return m + }(), }) } else { d.msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: meta.Channel, - ChatID: meta.ChatID, - Content: content, + Channel: meta.Channel, + ChatID: meta.ChatID, + Content: content, + Metadata: buildAnnounceOutMeta(meta.LocalKey), }) } }) @@ -243,6 +250,7 @@ func (d *gatewayDeps) wireTeamProgressNotifySubscriber() { UserID: payload.UserID, LeadAgent: leadAgentKey, PeerKind: payload.PeerKind, + LocalKey: payload.LocalKey, }) }) slog.Info("team progress notification subscriber registered") diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index abc93157..10bbbb83 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -226,6 +226,7 @@ func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx c chatReq.Options[providers.OptChannel] = req.Channel chatReq.Options[providers.OptChatID] = req.ChatID chatReq.Options[providers.OptPeerKind] = req.PeerKind + chatReq.Options[providers.OptLocalKey] = req.LocalKey chatReq.Options[providers.OptWorkspace] = tools.ToolWorkspaceFromCtx(ctx) if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { chatReq.Options[providers.OptTenantID] = tid.String() diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 065e7c4b..56c603ef 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -185,7 +185,7 @@ func (s *Server) BuildMux() *http.ServeMux { if s.cfg.Gateway.Token != "" { bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0", s.msgBus) handler := tokenAuthMiddleware(s.cfg.Gateway.Token, - bridgeContextMiddleware(s.cfg.Gateway.Token, bridgeHandler)) + bridgeContextMiddleware(s.cfg.Gateway.Token, s.agentStore, bridgeHandler)) mux.Handle("/mcp/bridge", handler) } else { slog.Warn("security.mcp_bridge_disabled: no gateway token configured, MCP bridge is disabled") @@ -212,7 +212,7 @@ func (s *Server) BuildMux() *http.ServeMux { // access agent/user scope and resolve workspace-relative paths. // When a gateway token is configured, the context headers must be accompanied by // a valid X-Bridge-Sig HMAC to prevent forgery. -func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handler { +func bridgeContextMiddleware(gatewayToken string, agentStore store.AgentStore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() agentIDStr := r.Header.Get("X-Agent-ID") @@ -221,6 +221,8 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle chatID := r.Header.Get("X-Chat-ID") peerKind := r.Header.Get("X-Peer-Kind") workspace := r.Header.Get("X-Workspace") + localKey := r.Header.Get("X-Local-Key") + sessionKey := r.Header.Get("X-Session-Key") if agentIDStr != "" || userID != "" { // Reject context headers when no gateway token — prevents unauthenticated impersonation. @@ -234,7 +236,7 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle // Verify HMAC signature over all context fields. tenantIDStr := r.Header.Get("X-Tenant-ID") sig := r.Header.Get("X-Bridge-Sig") - ok, tenantVerified := providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig) + ok, tenantVerified := providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig, localKey, sessionKey) if !ok { slog.Warn("security.mcp_bridge: invalid bridge context signature", "agent_id", agentIDStr, "user_id", userID) @@ -245,6 +247,18 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle if agentIDStr != "" { if id, err := uuid.Parse(agentIDStr); err == nil { ctx = store.WithAgentID(ctx, id) + + // Inject per-agent shell deny group overrides so the exec tool + // respects the same policy as the normal agent loop. + if agentStore != nil { + ag, err := agentStore.GetByIDUnscoped(ctx, id) + if err == nil && ag != nil { + groups := ag.ParseShellDenyGroups() + if groups != nil { + ctx = store.WithShellDenyGroups(ctx, groups) + } + } + } } } if userID != "" { @@ -274,6 +288,15 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle if workspace != "" && (agentIDStr != "" || userID != "") { ctx = tools.WithToolWorkspace(ctx, workspace) } + // Routing context (localKey, sessionKey) is injected unconditionally like channel/chatID. + // These are used for message routing (forum topics), not security-sensitive operations. + // Without valid agent context, tool execution will fail anyway. + if localKey != "" { + ctx = tools.WithToolLocalKey(ctx, localKey) + } + if sessionKey != "" { + ctx = tools.WithToolSessionKey(ctx, sessionKey) + } next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go index fb9a31f1..bb3646c4 100644 --- a/internal/mcp/bridge_server.go +++ b/internal/mcp/bridge_server.go @@ -98,7 +98,15 @@ func makeToolHandler(reg *tools.Registry, toolName string, msgBus *bus.MessageBu return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { args := req.GetArguments() - result := reg.Execute(ctx, toolName, args) + // Pass routing context (channel, chatID, peerKind, sessionKey) so native + // tools can access local_key, session_key etc. for forum topic routing. + result := reg.ExecuteWithContext(ctx, toolName, args, + tools.ToolChannelFromCtx(ctx), + tools.ToolChatIDFromCtx(ctx), + tools.ToolPeerKindFromCtx(ctx), + tools.ToolSessionKeyFromCtx(ctx), + nil, + ) if result.IsError { return mcpgo.NewToolResultError(result.ForLLM), nil diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go index dc1cd164..e5e0dde7 100644 --- a/internal/providers/claude_cli.go +++ b/internal/providers/claude_cli.go @@ -35,6 +35,9 @@ const OptWorkspace = "workspace" // Required for memory indexing and tenant-scoped queries via bridge tools. const OptTenantID = "tenant_id" +// OptLocalKey passes the composite local key (e.g. "-100123:topic:42") for forum topic routing. +const OptLocalKey = "local_key" + // ClaudeCLIProvider implements Provider by shelling out to the `claude` CLI binary. // It acts as a thin proxy: CLI manages session history, tool execution, and context. // GoClaw only forwards the latest user message and streams back the response. diff --git a/internal/providers/claude_cli_mcp.go b/internal/providers/claude_cli_mcp.go index 80fa2517..ae31e41c 100644 --- a/internal/providers/claude_cli_mcp.go +++ b/internal/providers/claude_cli_mcp.go @@ -91,6 +91,7 @@ type BridgeContext struct { PeerKind string Workspace string TenantID string + LocalKey string } // WriteMCPConfig writes a per-session MCP config file with agent context headers. @@ -98,10 +99,10 @@ type BridgeContext struct { // outside the agent's workDir so tokens are not exposed. // Skips write if content is unchanged. Returns the file path. func (d *MCPConfigData) WriteMCPConfig(ctx context.Context, sessionKey string, bc BridgeContext) string { - return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID) + return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID, bc.LocalKey) } -func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string { +func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID, localKey string) string { if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) { return "" } @@ -151,9 +152,15 @@ func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, if tenantID != "" && !strings.ContainsAny(tenantID, "\r\n\x00") { headers["X-Tenant-ID"] = tenantID } + if localKey != "" && !strings.ContainsAny(localKey, "\r\n\x00") { + headers["X-Local-Key"] = localKey + } + if sessionKey != "" && !strings.ContainsAny(sessionKey, "\r\n\x00") { + headers["X-Session-Key"] = sessionKey + } // HMAC signature over all context fields to prevent header forgery if d.GatewayToken != "" && (agentID != "" || userID != "") { - headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID) + headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID, localKey, sessionKey) } bridgeEntry := map[string]any{ @@ -257,9 +264,13 @@ func sanitizePathSegment(s string) string { // SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery. // Payload: agentID|userID|channel|chatID|peerKind|workspace|tenantID -func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string { +func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string, extra ...string) string { mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID)) + payload := agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID + for _, e := range extra { + payload += "|" + e + } + mac.Write([]byte(payload)) return hex.EncodeToString(mac.Sum(nil)) } @@ -269,12 +280,17 @@ func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspac // Falls back to old formats for backward compatibility with sessions whose MCP config // was written before the workspace or tenantID fields were added. // Callers must NOT trust the tenantID header when tenantVerified is false. -func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string) (bool, bool) { - // Current format: all fields including tenantID - expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID) +func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string, extra ...string) (bool, bool) { + // Current format: all fields including localKey + expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, extra...) if hmac.Equal([]byte(expected), []byte(sig)) { return true, true } + // Fallback: without extra fields (pre-localKey sessions) + noExtra := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID) + if hmac.Equal([]byte(noExtra), []byte(sig)) { + return true, true + } // Fallback: without tenantID (pre-tenantID sessions) noTenant := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, "") if hmac.Equal([]byte(noTenant), []byte(sig)) { diff --git a/internal/providers/claude_cli_mcp_test.go b/internal/providers/claude_cli_mcp_test.go index 527cd21d..7622557a 100644 --- a/internal/providers/claude_cli_mcp_test.go +++ b/internal/providers/claude_cli_mcp_test.go @@ -118,3 +118,64 @@ func TestVerifyBridgeContext_EmptyFields(t *testing.T) { t.Error("expected tenantVerified=true when all fields empty (level 1 matches)") } } + +// --- Extra params (localKey, sessionKey) tests --- + +func TestSignBridgeContext_WithExtraParams(t *testing.T) { + key := "test-secret" + // Without extra params + sig1 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1") + // With extra params + sig2 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", "-100123:topic:42", "session-abc") + + if sig1 == sig2 { + t.Error("signature with extra params should differ from signature without") + } +} + +func TestVerifyBridgeContext_WithExtraParams(t *testing.T) { + key := "gateway-token" + localKey := "-100123:topic:42" + sessionKey := "session-abc" + sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", localKey, sessionKey) + + ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", sig, localKey, sessionKey) + if !ok { + t.Error("expected ok=true for valid signature with extra params") + } + if !tenantVerified { + t.Error("expected tenantVerified=true for full match") + } +} + +func TestVerifyBridgeContext_FallbackWithoutExtraParams(t *testing.T) { + key := "gateway-token" + // Pre-localKey session: signed without extra params + sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1") + + // New code passes localKey/sessionKey but signature was created without them + ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", sig, "-100123:topic:42", "session-abc") + if !ok { + t.Error("expected ok=true for fallback (pre-localKey session)") + } + if !tenantVerified { + t.Error("expected tenantVerified=true — base fields match at fallback level") + } +} + +func TestVerifyBridgeContext_ExtraParamOrderMatters(t *testing.T) { + key := "gateway-token" + sig := SignBridgeContext(key, "agent1", "user1", "", "", "", "", "", "localKey", "sessionKey") + + // Verify with same order + ok, _ := VerifyBridgeContext(key, "agent1", "user1", "", "", "", "", "", sig, "localKey", "sessionKey") + if !ok { + t.Error("expected ok=true for same order") + } + + // Verify with swapped order + ok2, _ := VerifyBridgeContext(key, "agent1", "user1", "", "", "", "", "", sig, "sessionKey", "localKey") + if ok2 { + t.Error("expected ok=false for swapped extra param order") + } +} diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go index fdaaee0d..52c879f0 100644 --- a/internal/providers/claude_cli_session.go +++ b/internal/providers/claude_cli_session.go @@ -167,6 +167,7 @@ func bridgeContextFromOpts(opts map[string]any) BridgeContext { PeerKind: extractStringOpt(opts, OptPeerKind), Workspace: extractStringOpt(opts, OptWorkspace), TenantID: extractStringOpt(opts, OptTenantID), + LocalKey: extractStringOpt(opts, OptLocalKey), } } diff --git a/internal/tools/team_event_helpers.go b/internal/tools/team_event_helpers.go index a7a66fae..eff3494f 100644 --- a/internal/tools/team_event_helpers.go +++ b/internal/tools/team_event_helpers.go @@ -95,6 +95,11 @@ func WithPeerKind(pk string) TaskEventOption { return func(p *protocol.TeamTaskEventPayload) { p.PeerKind = pk } } +// WithLocalKey sets LocalKey on the payload for forum topic routing. +func WithLocalKey(lk string) TaskEventOption { + return func(p *protocol.TeamTaskEventPayload) { p.LocalKey = lk } +} + // WithCommentText sets CommentText on the payload. func WithCommentText(t string) TaskEventOption { return func(p *protocol.TeamTaskEventPayload) { p.CommentText = t } @@ -116,6 +121,7 @@ func WithContextInfo(ctx context.Context) TaskEventOption { p.Channel = ToolChannelFromCtx(ctx) p.ChatID = ToolChatIDFromCtx(ctx) p.PeerKind = ToolPeerKindFromCtx(ctx) + p.LocalKey = ToolLocalKeyFromCtx(ctx) } } diff --git a/internal/tools/team_notify_queue.go b/internal/tools/team_notify_queue.go index fc720639..8181060a 100644 --- a/internal/tools/team_notify_queue.go +++ b/internal/tools/team_notify_queue.go @@ -14,6 +14,7 @@ type NotifyRoutingMeta struct { UserID string LeadAgent string // agent key (only used in leader mode) PeerKind string // "group" or "direct" — routes to correct session (#266) + LocalKey string // composite key with topic suffix for forum routing } // TeamNotifyQueue batches team task notifications per chat with debounce, diff --git a/internal/tools/team_tasks_blocker.go b/internal/tools/team_tasks_blocker.go index 6e486428..b2d36dbc 100644 --- a/internal/tools/team_tasks_blocker.go +++ b/internal/tools/team_tasks_blocker.go @@ -49,6 +49,10 @@ func (t *TeamTasksTool) handleBlockerComment( if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok { blockerPeerKind = pk } + blockerLocalKey := "" + if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok { + blockerLocalKey = lk + } t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, BuildTaskEventPayload( team.ID.String(), taskID.String(), store.TeamTaskStatusFailed, @@ -60,6 +64,7 @@ func (t *TeamTasksTool) handleBlockerComment( WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(blockerPeerKind), + WithLocalKey(blockerLocalKey), )) // Escalate to leader if enabled in team settings. diff --git a/internal/tools/team_tasks_create.go b/internal/tools/team_tasks_create.go index f7b9965f..ceac0101 100644 --- a/internal/tools/team_tasks_create.go +++ b/internal/tools/team_tasks_create.go @@ -301,6 +301,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(ToolPeerKindFromCtx(ctx)), + WithLocalKey(ToolLocalKeyFromCtx(ctx)), )) t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID) } diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index 51e6f0be..b3f28dc2 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -363,6 +363,10 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok { taskPeerKind = pk } + taskLocalKey := "" + if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok { + taskLocalKey = lk + } m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload( teamID.String(), task.ID.String(), store.TeamTaskStatusInProgress, @@ -372,6 +376,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(taskPeerKind), + WithLocalKey(taskLocalKey), )) // Append completed blocker results so the member agent has context. diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index 81f0dc57..7266f2a2 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -102,6 +102,10 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok { taskPeerKind = pk } + taskLocalKey := "" + if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok { + taskLocalKey = lk + } m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload( teamID.String(), task.ID.String(), store.TeamTaskStatusInProgress, @@ -111,6 +115,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(taskPeerKind), + WithLocalKey(taskLocalKey), )) // Restore leader's trace context from task metadata (ctx here is the // consumer goroutine context which has no trace after the turn ends). diff --git a/pkg/protocol/team_events.go b/pkg/protocol/team_events.go index 5af4fcf0..0b54d5f2 100644 --- a/pkg/protocol/team_events.go +++ b/pkg/protocol/team_events.go @@ -101,6 +101,7 @@ type TeamTaskEventPayload struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` PeerKind string `json:"peer_kind,omitempty"` // "group" or "direct" — for correct session routing (#266) + LocalKey string `json:"local_key,omitempty"` Timestamp string `json:"timestamp"` // Comment text preview (for team.task.commented events, truncated).