diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 4de6735e..b62c9b50 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -519,6 +519,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) providers.OptChatID: req.ChatID, providers.OptPeerKind: req.PeerKind, providers.OptWorkspace: tools.ToolWorkspaceFromCtx(ctx), + providers.OptTenantID: store.TenantIDFromContext(ctx).String(), }, } if l.thinkingLevel != "" && l.thinkingLevel != "off" { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c7e48f5f..382ac0be 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -225,8 +225,9 @@ 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") - if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, sig) { + if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig) { slog.Warn("security.mcp_bridge: invalid bridge context signature", "agent_id", agentIDStr, "user_id", userID) http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden) @@ -241,6 +242,11 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle if userID != "" { ctx = store.WithUserID(ctx, userID) } + if tenantIDStr != "" { + if tid, err := uuid.Parse(tenantIDStr); err == nil { + ctx = store.WithTenantID(ctx, tid) + } + } } // Inject channel routing context for tools like message, cron, etc. diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go index 8b2f88e7..49849133 100644 --- a/internal/providers/claude_cli.go +++ b/internal/providers/claude_cli.go @@ -31,6 +31,10 @@ const OptPeerKind = "peer_kind" // OptWorkspace passes the agent workspace path so MCP bridge tools can resolve file paths. const OptWorkspace = "workspace" +// OptTenantID passes the tenant UUID string for per-session MCP config. +// Required for memory indexing and tenant-scoped queries via bridge tools. +const OptTenantID = "tenant_id" + // 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 b4fa6adb..dcdda2a9 100644 --- a/internal/providers/claude_cli_mcp.go +++ b/internal/providers/claude_cli_mcp.go @@ -90,6 +90,7 @@ type BridgeContext struct { ChatID string PeerKind string Workspace string + TenantID string } // WriteMCPConfig writes a per-session MCP config file with agent context headers. @@ -97,10 +98,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) + return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID) } -func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace string) string { +func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string { if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) { return "" } @@ -147,9 +148,12 @@ func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, if workspace != "" && !strings.ContainsAny(workspace, "\r\n\x00") { headers["X-Workspace"] = workspace } + if tenantID != "" && !strings.ContainsAny(tenantID, "\r\n\x00") { + headers["X-Tenant-ID"] = tenantID + } // 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) + headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID) } bridgeEntry := map[string]any{ @@ -252,22 +256,28 @@ func sanitizePathSegment(s string) string { } // SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery. -// Payload: agentID|userID|channel|chatID|peerKind|workspace -func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace string) string { +// Payload: agentID|userID|channel|chatID|peerKind|workspace|tenantID +func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string { mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace)) + mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID)) return hex.EncodeToString(mac.Sum(nil)) } // VerifyBridgeContext checks the HMAC signature against the expected bridge context. -// Falls back to old format (without workspace) for backward compatibility with -// sessions whose MCP config was written before the workspace field was added. -func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, sig string) bool { - expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace) +// Falls back to old formats for backward compatibility with sessions whose MCP config +// was written before the workspace or tenantID fields were added. +func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string) bool { + // Current format: all fields including tenantID + expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID) if hmac.Equal([]byte(expected), []byte(sig)) { return true } - // Fallback: verify old format (without workspace) for backward compat. - old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "") + // Fallback: without tenantID (pre-tenantID sessions) + noTenant := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, "") + if hmac.Equal([]byte(noTenant), []byte(sig)) { + return true + } + // Fallback: without workspace or tenantID (oldest sessions) + old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "", "") return hmac.Equal([]byte(old), []byte(sig)) } diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go index 61f56e16..047e8718 100644 --- a/internal/providers/claude_cli_session.go +++ b/internal/providers/claude_cli_session.go @@ -166,6 +166,7 @@ func bridgeContextFromOpts(opts map[string]any) BridgeContext { ChatID: extractStringOpt(opts, OptChatID), PeerKind: extractStringOpt(opts, OptPeerKind), Workspace: extractStringOpt(opts, OptWorkspace), + TenantID: extractStringOpt(opts, OptTenantID), } }