diff --git a/python/composio/core/models/custom_tools.py b/python/composio/core/models/custom_tools.py deleted file mode 100644 index 92d7b2d2d..000000000 --- a/python/composio/core/models/custom_tools.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Custom Tools module. - -Security model — SEC-365 / CWE-639 (cross-tenant credential access) -------------------------------------------------------------------- - -Custom tools execute against the caller's OAuth credentials, looked up by -``user_id``. The LLM supplies the *request* arguments for a tool call; the -SDK supplies the ``user_id``. These two inputs MUST stay structurally -separate — if they ever collapse into one ``**kwargs`` dict, an LLM (or a -prompt-injection input) can set ``user_id`` and have us load another -tenant's tokens. - -The trust boundary is ``CustomTools.execute(slug, request, user_id)``: - - * ``request`` is treated as untrusted and is filtered through an - *allowlist* of fields declared on the tool's Pydantic ``request_model``, - so today's ``user_id`` smuggling — and any future identity-bearing key - that might be added to the auth path (``tenant_id``, ``org_id``, …) — - is dropped before it can reach credential lookup. - * ``user_id`` is forwarded to ``CustomTool.invoke_trusted(user_id, request_kwargs)`` - as a structurally separate parameter. - -``CustomTool.__call__`` (the public ``tool(**kwargs)`` shorthand) raises -``TypeError`` if ``user_id`` appears in ``kwargs`` — refusing the smuggled -key loudly rather than silently using a fallback that could collide with a -real tenant. -""" - -import functools -import inspect -import typing as t - -from pydantic import BaseModel - -from composio.client import HttpClient, NotGiven -from composio.client.types import ( - Tool, - tool_list_response, - tool_proxy_params, - tool_proxy_response, -) -from composio.exceptions import ( - NotFoundError, -) - - -class ExecuteRequestFn(t.Protocol): - def __call__( - self, - endpoint: str, - method: t.Literal["GET", "POST", "PUT", "DELETE", "PATCH"], - body: t.Dict | NotGiven = HttpClient.not_given, - connected_account_id: str | NotGiven = HttpClient.not_given, - parameters: ( - t.Iterable[tool_proxy_params.Parameter] | NotGiven - ) = HttpClient.not_given, - ) -> tool_proxy_response.ToolProxyResponse: ... - - -class CustomToolProtocol(t.Protocol): - def __call__(self, request: t.Any) -> t.Any: ... - - -class CustomToolWithProxyProtocol(t.Protocol): - def __call__( - self, - request: t.Any, - execute_request: ExecuteRequestFn, - auth_credentials: t.Dict, - ) -> t.Any: ... - - -CustomToolFn = t.Union[ - CustomToolProtocol, - CustomToolWithProxyProtocol, -] - - -class CustomTool: - request_model: t.Type[BaseModel] - - def __init__( - self, - f: t.Callable[..., t.Any], - client: HttpClient, - toolkit: t.Optional[str] = None, - ): - """ - Initialize the custom tool. - - :param f: The function to wrap. - :param client: The client to use for the custom tool. - :param toolkit: The toolkit to use for the custom tool. - """ - self.f = t.cast(CustomToolFn, f) - self.name, self.slug = f.__name__, f.__name__.upper() - self.description = t.cast(str, f.__doc__) - self.toolkit = toolkit - self.client = client - if self.toolkit is not None: - self.name = f"{self.toolkit.lower()}_{self.name}" - self.slug = f"{self.toolkit.upper()}_{self.slug}" - self.info = self.__parse_info() - - def __parse_info(self) -> Tool: - """Parse the parameters of the custom tool.""" - if self.description is None: - raise ValueError(f"Description is required on tool {self.name}") - - self.request_model = inspect.signature(self.f).parameters["request"].annotation - return Tool( - name=self.name, - description=self.description, - input_parameters=self.request_model.model_json_schema(), - output_parameters={}, - available_versions=[], - version="1.0.0", - scopes=[], - slug=self.slug, - toolkit=tool_list_response.ItemToolkit( - logo="", - name=self.toolkit or "custom", - slug=(self.toolkit or "custom").upper(), - ), - deprecated=tool_list_response.ItemDeprecated( - available_versions=[], - displayName=self.name, - version="1.0.0", - toolkit=tool_list_response.ItemDeprecatedToolkit(logo=str()), - is_deprecated=False, - ), - is_deprecated=False, - no_auth=False, - tags=[], - ) - - def __get_auth_credentials(self, user_id: str) -> dict: - """Get the auth config for the custom tool.""" - if self.toolkit is None: - raise ValueError("Toolkit is required for custom tools") - - connected_accounts = self.client.connected_accounts.list( - toolkit_slugs=[self.toolkit], - user_ids=[user_id], - ) - - if len(connected_accounts.items) == 0: - raise ValueError( - f"No connected accounts found for toolkit {self.toolkit} and user {user_id}" - ) - - account, *_ = sorted( - connected_accounts.items, - key=lambda x: x.created_at, - reverse=True, - ) - return account.state.val.model_dump() - - def __call__(self, **kwargs: t.Any) -> t.Any: - """Call the custom tool with the default ``user_id``. - - Refuses ``user_id`` in ``kwargs`` with ``TypeError`` rather than - silently dropping it — see the module docstring for why. Use - ``CustomTools.execute(slug, request, user_id=...)`` (the trusted - entry point) when you need a non-default ``user_id``. - """ - if "user_id" in kwargs: - raise TypeError( - "CustomTool.__call__ does not accept user_id; " - "use CustomTools.execute(slug, request, user_id=...) instead." - ) - return self.invoke_trusted(user_id="default", request_kwargs=kwargs) - - def invoke_trusted( - self, - user_id: str, - request_kwargs: t.Dict[str, t.Any], - ) -> t.Any: - """Trusted entry point used by ``CustomTools.execute``. - - ``user_id`` is taken as a structurally separate parameter, so an - LLM-controlled ``user_id`` key sitting inside ``request_kwargs`` - cannot override it (the request model's default ``extra='ignore'`` - means any such key is also dropped during validation). - """ - request = self.request_model.model_validate(request_kwargs) - if self.toolkit is None: - return t.cast(CustomToolProtocol, self.f)(request=request) - - return t.cast(CustomToolWithProxyProtocol, self.f)( - request=request, - execute_request=t.cast(ExecuteRequestFn, self.client.tools.proxy), - auth_credentials=self.__get_auth_credentials(user_id), - ) - - -def _allowed_request_field_names(request_model: t.Type[BaseModel]) -> t.Set[str]: - """Names accepted by ``request_model`` (canonical name + alias).""" - allowed: t.Set[str] = set() - for field_name, field_info in request_model.model_fields.items(): - allowed.add(field_name) - if field_info.alias is not None: - allowed.add(field_info.alias) - return allowed - - -class CustomTools: - """CustomTools class - - Used to manage custom tools created by users.""" - - def __init__(self, client: HttpClient): - self.client = client - self.custom_tools_registry: dict[str, CustomTool] = {} - - def __getitem__(self, slug: str) -> CustomTool: - """Get a custom tool by its slug.""" - return self.custom_tools_registry[slug] - - def get(self, slug: str) -> t.Optional[CustomTool]: - """Get a custom tool by its slug.""" - try: - return self.custom_tools_registry[slug] - except KeyError: - return None - - @t.overload - def register(self, f: CustomToolProtocol) -> CustomTool: ... - - @t.overload - def register( - self, *, toolkit: t.Optional[str] = None - ) -> t.Callable[[CustomToolWithProxyProtocol], CustomTool]: ... - - def register( - self, - f: t.Optional[CustomToolProtocol] = None, - *, - toolkit: t.Optional[str] = None, - ) -> t.Union[ - CustomTool, - t.Callable[[CustomToolProtocol], CustomTool], - t.Callable[[CustomToolWithProxyProtocol], CustomTool], - ]: - """Register a custom tool.""" - if f is not None: - return self._wrap_tool(f, toolkit) - return functools.partial(self._wrap_tool, toolkit=toolkit) - - def _wrap_tool( - self, - f: CustomToolFn, - toolkit: t.Optional[str] = None, - ) -> CustomTool: - """Wrap a tool function.""" - tool = CustomTool(f=f, client=self.client, toolkit=toolkit) - self.custom_tools_registry[tool.slug] = tool - return tool - - def execute( - self, - slug: str, - request: t.Dict, - user_id: t.Optional[str] = None, - ) -> t.Any: - """Execute a custom tool — the trust boundary for LLM-supplied args. - - ``request`` is filtered through an allowlist of fields declared on - the tool's Pydantic ``request_model`` (canonical names + aliases). - Anything else — including the historical ``user_id`` smuggling - vector and any future identity-bearing keys — is dropped before - the call reaches credential lookup. ``user_id`` is forwarded as a - structurally separate parameter; see the module docstring for the - full security model. - """ - custom_tool = self.get(slug) - if custom_tool is None: - raise NotFoundError(f"Custom tool with slug {slug} not found") - allowed = _allowed_request_field_names(custom_tool.request_model) - sanitized_request = {k: v for k, v in request.items() if k in allowed} - return custom_tool.invoke_trusted( - user_id=user_id or "default", - request_kwargs=sanitized_request, - ) diff --git a/python/composio/core/models/tools.py b/python/composio/core/models/tools.py index 22b7a9d28..41b94714a 100644 --- a/python/composio/core/models/tools.py +++ b/python/composio/core/models/tools.py @@ -18,7 +18,6 @@ from composio.client.types import ( from composio.core.models._files import FileHelper from composio.core.models.base import Resource from composio.core.models.custom_tool_types import InlineCustomToolsWirePayload -from composio.core.models.custom_tools import CustomTools from composio.core.models.inline_custom_tools_payload import ( inline_custom_tools_execute_experimental, ) @@ -28,7 +27,7 @@ from composio.core.provider.base import ExecuteToolFn from composio.core.provider.base import BaseProvider from composio.core.provider.none_agentic import NonAgenticProvider from composio.core.types import ToolkitVersionParam -from composio.exceptions import InvalidParams, NotFoundError, ToolVersionRequiredError +from composio.exceptions import InvalidParams, ToolVersionRequiredError from composio.utils.pydantic import none_to_omit from composio.utils.toolkit_version import get_toolkit_version from composio.utils.upload_dir_allowlist import resolve_effective_upload_allowlist @@ -144,7 +143,6 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): ) self._client = client - self._custom_tools = CustomTools(client) self._tool_schemas: t.Dict[str, Tool] = {} self._file_helper = FileHelper( client=self._client, @@ -155,7 +153,6 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): ) self._toolkit_versions = toolkit_versions - self.custom_tool = self._custom_tools.register self.provider = provider self.provider.set_execute_tool_fn( @@ -169,33 +166,17 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): ), ) - def _filter_custom_tools( - self, tools: t.List[str] - ) -> t.Tuple[t.List[str], t.List[Tool]]: - """Filter out custom tools from the list of tools.""" - _tools = [] - _custom_tools = [] - for tool in tools: - try: - _custom_tools.append(self._custom_tools[tool].info) - except KeyError: - _tools.append(tool) - return _tools, _custom_tools - def get_raw_composio_tool_by_slug(self, slug: str) -> Tool: """ Returns schema for the given tool slug. """ - try: - return t.cast(Tool, self._custom_tools[slug]) - except KeyError: - return t.cast( - Tool, - self._client.tools.retrieve( - tool_slug=slug, - toolkit_versions=none_to_omit(self._toolkit_versions), - ), - ) + return t.cast( + Tool, + self._client.tools.retrieve( + tool_slug=slug, + toolkit_versions=none_to_omit(self._toolkit_versions), + ), + ) def get_raw_composio_tools( self, @@ -215,8 +196,6 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): tools_list = [] if tools is not None: - tools, custom_tools = self._filter_custom_tools(tools=tools) - tools_list.extend(custom_tools) if len(tools): tools_list.extend( self._client.tools.list( @@ -488,11 +467,6 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): :return: Tool execution response """ tool = self._tool_schemas.get(slug) - if tool is None: - custom_tool = self._custom_tools.get(slug=slug) - if custom_tool is not None: - tool = custom_tool.info - self._tool_schemas[slug] = tool if tool is None: tool = t.cast( @@ -572,31 +546,6 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): return t.cast(AgenticProviderExecuteFn, execute_tool_fn) - def _execute_custom_tool( - self, - slug: str, - arguments: t.Dict, - user_id: t.Optional[str] = None, - ) -> ToolExecutionResponse: - """Execute a custom tool""" - # TODO: Better error handling, pydantic validation eg... - try: - return { - "data": self._custom_tools.execute( - slug=slug, - request=arguments, - user_id=user_id, - ), - "error": None, - "successful": True, - } - except NotFoundError: - return { - "data": {}, - "error": f"Tool with slug {slug} not found", - "successful": False, - } - def _execute_tool( self, slug: str, @@ -669,9 +618,7 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): """ Execute a tool with the provided parameters. - This method calls the Composio API or a custom tool handler to execute - the tool and returns the response. It automatically determines whether - to use a custom tool or a Composio API tool based on the slug. + This method calls the Composio API to execute the tool and returns the response. :param slug: The slug of the tool to execute. :param arguments: The arguments to pass to the tool. @@ -687,11 +634,6 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): """ tool = self._tool_schemas.get(slug) - if tool is None: - custom_tool = self._custom_tools.get(slug=slug) - if custom_tool is not None: - tool = custom_tool.info - self._tool_schemas[slug] = tool if tool is None: tool = t.cast( @@ -760,24 +702,16 @@ class Tools(Resource, t.Generic[TTool, TToolCollection]): dangerously_skip_version_check = processed_params.get( "dangerously_skip_version_check", dangerously_skip_version_check ) - response = ( - self._execute_custom_tool( - slug=slug, - arguments=arguments, - user_id=user_id, - ) - if self._custom_tools.get(slug) is not None - else self._execute_tool( - slug=slug, - arguments=arguments, - connected_account_id=connected_account_id, - custom_auth_params=custom_auth_params, - custom_connection_data=custom_connection_data, - user_id=user_id, - text=text, - version=version, - dangerously_skip_version_check=dangerously_skip_version_check, - ) + response = self._execute_tool( + slug=slug, + arguments=arguments, + connected_account_id=connected_account_id, + custom_auth_params=custom_auth_params, + custom_connection_data=custom_connection_data, + user_id=user_id, + text=text, + version=version, + dangerously_skip_version_check=dangerously_skip_version_check, ) if self._auto_upload_download_files: response = self._file_helper.substitute_file_downloads( diff --git a/python/composio/types.py b/python/composio/types.py index 2a654e5d2..fb77fa0a9 100644 --- a/python/composio/types.py +++ b/python/composio/types.py @@ -1,6 +1,5 @@ from composio.client.types import Tool from composio.core.models.connected_accounts import auth_scheme -from composio.core.models.custom_tools import ExecuteRequestFn from composio.core.models.tools import ( Modifiers, ToolExecuteParams, @@ -22,7 +21,6 @@ __all__ = [ "TToolCollection", "ToolExecuteParams", "ToolExecutionResponse", - "ExecuteRequestFn", "TriggerEvent", "Modifiers", "auth_scheme", diff --git a/python/examples/custom_tools.py b/python/examples/custom_tools.py deleted file mode 100644 index b06a036c9..000000000 --- a/python/examples/custom_tools.py +++ /dev/null @@ -1,83 +0,0 @@ -import requests -from pydantic import BaseModel, Field - -from composio import Composio -from composio.types import ExecuteRequestFn - -composio = Composio() - - -class AddTwoNumbersInput(BaseModel): - a: int = Field( - ..., - description="The first number to add", - ) - b: int = Field( - ..., - description="The second number to add", - ) - - -@composio.tools.custom_tool -def add_two_numbers(request: AddTwoNumbersInput) -> int: - """Add two numbers.""" - return request.a + request.b - - -class GetIssueInfoInput(BaseModel): - issue_number: int = Field( - ..., - description="The number of the issue to get information about", - ) - - -@composio.tools.custom_tool(toolkit="github") -def get_issue_info( - request: GetIssueInfoInput, - execute_request: ExecuteRequestFn, - auth_credentials: dict, -) -> dict: - """Get information about a GitHub issue.""" - response = execute_request( - endpoint=f"/repos/composiohq/composio/issues/{request.issue_number}", - method="GET", - parameters=[ - { - "name": "Accept", - "value": "application/vnd.github.v3+json", - "type": "header", - }, - { - "name": "Authorization", - "value": f"Bearer {auth_credentials['access_token']}", - "type": "header", - }, - ], - ) - return {"data": response.data} - - -@composio.tools.custom_tool(toolkit="github") -def get_issue_info_direct( - request: GetIssueInfoInput, - execute_request: ExecuteRequestFn, - auth_credentials: dict, -) -> dict: - """Get information about a GitHub issue.""" - response = requests.get( - f"https://api.github.com/repos/composiohq/composio/issues/{request.issue_number}", - headers={ - "Accept": "application/vnd.github.v3+json", - "Authorization": f"Bearer {auth_credentials['access_token']}", - }, - ) - return {"data": response.json()} - - -response = composio.tools.execute( - user_id="default", - slug=get_issue_info.slug, - arguments={"issue_number": 1}, -) - -print(response) diff --git a/python/providers/anthropic/pyproject.toml b/python/providers/anthropic/pyproject.toml index 23388d0f1..b568f719f 100644 --- a/python/providers/anthropic/pyproject.toml +++ b/python/providers/anthropic/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-anthropic" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your Anthropic LLMs." readme = "README.md" requires-python = ">=3.10,<4" diff --git a/python/providers/anthropic/setup.py b/python/providers/anthropic/setup.py index aa2bfb7d9..78d91b7a7 100644 --- a/python/providers/anthropic/setup.py +++ b/python/providers/anthropic/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_anthropic", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your Anthropic LLMs.", diff --git a/python/providers/autogen/pyproject.toml b/python/providers/autogen/pyproject.toml index 8944c4172..6e2cda895 100644 --- a/python/providers/autogen/pyproject.toml +++ b/python/providers/autogen/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-autogen" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your autogen agent." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/autogen/setup.py b/python/providers/autogen/setup.py index 49220730d..299e09d31 100644 --- a/python/providers/autogen/setup.py +++ b/python/providers/autogen/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_autogen", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your Autogen agent.", diff --git a/python/providers/claude_agent_sdk/pyproject.toml b/python/providers/claude_agent_sdk/pyproject.toml index b160e2e03..14cdd2a6a 100644 --- a/python/providers/claude_agent_sdk/pyproject.toml +++ b/python/providers/claude_agent_sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-claude-agent-sdk" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with Claude Code Agents SDK." readme = "README.md" requires-python = ">=3.10,<4" diff --git a/python/providers/claude_agent_sdk/setup.py b/python/providers/claude_agent_sdk/setup.py index 83c5d8f1c..7054c9d17 100644 --- a/python/providers/claude_agent_sdk/setup.py +++ b/python/providers/claude_agent_sdk/setup.py @@ -8,7 +8,7 @@ from setuptools import find_packages, setup setup( name="composio_claude_agent_sdk", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get array of tools for Claude Agent SDK", diff --git a/python/providers/crewai/pyproject.toml b/python/providers/crewai/pyproject.toml index 0ecbf5134..89291a5d4 100644 --- a/python/providers/crewai/pyproject.toml +++ b/python/providers/crewai/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-crewai" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your CrewAI agent." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/crewai/setup.py b/python/providers/crewai/setup.py index ea0a3e540..5c96e7951 100644 --- a/python/providers/crewai/setup.py +++ b/python/providers/crewai/setup.py @@ -6,7 +6,7 @@ from setuptools import setup setup( name="composio_crewai", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your CrewAI agent.", diff --git a/python/providers/gemini/pyproject.toml b/python/providers/gemini/pyproject.toml index e4b1232d0..d3cbf43ec 100644 --- a/python/providers/gemini/pyproject.toml +++ b/python/providers/gemini/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-gemini" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your Gemini agent." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/gemini/setup.py b/python/providers/gemini/setup.py index 8aeb1d42d..7f1e1edf3 100644 --- a/python/providers/gemini/setup.py +++ b/python/providers/gemini/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_gemini", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your Gemini agent.", diff --git a/python/providers/google/pyproject.toml b/python/providers/google/pyproject.toml index a163370ac..439e9b258 100644 --- a/python/providers/google/pyproject.toml +++ b/python/providers/google/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-google" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your Google AI Python Gemini model." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/google/setup.py b/python/providers/google/setup.py index 4d64cd4f0..b499d9932 100644 --- a/python/providers/google/setup.py +++ b/python/providers/google/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_google", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your Google AI Python Gemini model.", diff --git a/python/providers/google_adk/pyproject.toml b/python/providers/google_adk/pyproject.toml index 2b15a6d55..193e7acff 100644 --- a/python/providers/google_adk/pyproject.toml +++ b/python/providers/google_adk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-google-adk" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your Google AI Python Gemini model." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/google_adk/setup.py b/python/providers/google_adk/setup.py index 73eff6c55..4cf34f424 100644 --- a/python/providers/google_adk/setup.py +++ b/python/providers/google_adk/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_google_adk", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your Google AI Python Gemini model.", diff --git a/python/providers/langchain/pyproject.toml b/python/providers/langchain/pyproject.toml index d0d9b70c7..9d756acd2 100644 --- a/python/providers/langchain/pyproject.toml +++ b/python/providers/langchain/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-langchain" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your Langchain agent." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/langchain/setup.py b/python/providers/langchain/setup.py index 012d4373a..2dc099cc1 100644 --- a/python/providers/langchain/setup.py +++ b/python/providers/langchain/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_langchain", - version="0.13.1", + version="0.14.0", author="composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your Langchain agent.", diff --git a/python/providers/langgraph/pyproject.toml b/python/providers/langgraph/pyproject.toml index 8f0ab4f4d..9106ee3e8 100644 --- a/python/providers/langgraph/pyproject.toml +++ b/python/providers/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-langgraph" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get array of tools with LangGraph Agent Workflows." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/langgraph/setup.py b/python/providers/langgraph/setup.py index f768d88d5..487bbb543 100644 --- a/python/providers/langgraph/setup.py +++ b/python/providers/langgraph/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_langgraph", - version="0.13.1", + version="0.14.0", author="composio", author_email="tech@composio.dev", description="Use Composio to get array of tools with LangGraph Agent Workflows", diff --git a/python/providers/llamaindex/pyproject.toml b/python/providers/llamaindex/pyproject.toml index 3a9df2dab..56d2da0e6 100644 --- a/python/providers/llamaindex/pyproject.toml +++ b/python/providers/llamaindex/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-llamaindex" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get array of tools with LlamaIndex Agent Workflows." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/llamaindex/setup.py b/python/providers/llamaindex/setup.py index 90558a8f0..59a8c477c 100644 --- a/python/providers/llamaindex/setup.py +++ b/python/providers/llamaindex/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_llamaindex", - version="0.13.1", + version="0.14.0", author="composio", author_email="tech@composio.dev", description="Use Composio to get array of tools with LlamaIndex Agent Workflows", diff --git a/python/providers/openai/pyproject.toml b/python/providers/openai/pyproject.toml index 80d51686f..d36a0d0f7 100644 --- a/python/providers/openai/pyproject.toml +++ b/python/providers/openai/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-openai" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get an array of tools with your OpenAI Function Call." readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/openai/setup.py b/python/providers/openai/setup.py index 57d32f4dd..b16ceb958 100644 --- a/python/providers/openai/setup.py +++ b/python/providers/openai/setup.py @@ -8,7 +8,7 @@ from setuptools import setup setup( name="composio_openai", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get an array of tools with your OpenAI Function Call.", diff --git a/python/providers/openai_agents/pyproject.toml b/python/providers/openai_agents/pyproject.toml index 6959cec83..b75738ece 100644 --- a/python/providers/openai_agents/pyproject.toml +++ b/python/providers/openai_agents/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio-openai-agents" -version = "0.13.1" +version = "0.14.0" description = "Use Composio to get array of strongly typed tools for OpenAI Agents" readme = "README.md" requires-python = ">=3.9,<4" diff --git a/python/providers/openai_agents/setup.py b/python/providers/openai_agents/setup.py index 26e34edcd..ece829d75 100644 --- a/python/providers/openai_agents/setup.py +++ b/python/providers/openai_agents/setup.py @@ -8,7 +8,7 @@ from setuptools import find_packages, setup setup( name="composio_openai_agents", - version="0.13.1", + version="0.14.0", author="Composio", author_email="tech@composio.dev", description="Use Composio to get array of strongly typed tools for OpenAI Agents", diff --git a/python/pyproject.toml b/python/pyproject.toml index c9f427cd6..58ea07e96 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "composio" -version = "0.13.1" +version = "0.14.0" description = "SDK for integrating Composio with your applications." readme = "README.md" requires-python = ">=3.10,<4" diff --git a/python/tests/test_custom_tools_security.py b/python/tests/test_custom_tools_security.py deleted file mode 100644 index 80584206f..000000000 --- a/python/tests/test_custom_tools_security.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Security tests for the legacy ``CustomTool`` / ``CustomTools`` API. - -Regression tests for SEC-365 (CWE-639, cross-tenant credential access). - -Before the fix, ``CustomTool.__call__`` extracted ``user_id`` from the same -``**kwargs`` dict that carried LLM-supplied tool arguments, and -``CustomTools.execute`` happily forwarded that LLM-controlled dict via -``custom_tool(**request, user_id=user_id)``. An LLM (or prompt-injection -input) could therefore set ``user_id`` to another tenant's identifier and -have ``CustomTool.__get_auth_credentials`` look up that victim's OAuth -tokens. - -After the fix: - * ``CustomTools.execute`` filters the LLM-supplied ``request`` through - an *allowlist* of fields declared on the tool's Pydantic - ``request_model`` — ``user_id`` and any other unexpected key are - dropped before they can influence credential lookup or reach the - tool's execute function. - * ``CustomTool.invoke_trusted`` is the structurally separate trusted - entry point; ``user_id`` cannot be smuggled inside ``request_kwargs``. - * ``CustomTool.__call__`` raises ``TypeError`` if ``user_id`` appears - in ``kwargs`` rather than silently using a fallback. -""" - -from unittest.mock import MagicMock - -import pytest -from pydantic import BaseModel, Field - -from composio.core.models.custom_tools import CustomTool, CustomTools -from composio.exceptions import NotFoundError - - -class _IssueInput(BaseModel): - issue_number: int = Field(description="GitHub issue number") - - -@pytest.fixture -def mock_http_client() -> MagicMock: - """Mock HttpClient that returns a single connected account on lookup.""" - state = MagicMock() - state.val.model_dump.return_value = {"access_token": "trusted-token"} - - account = MagicMock() - account.created_at = "2026-01-01T00:00:00Z" - account.state = state - - response = MagicMock() - response.items = [account] - - client = MagicMock() - client.connected_accounts.list.return_value = response - client.tools.proxy = MagicMock() - return client - - -@pytest.fixture -def github_tool(mock_http_client: MagicMock) -> CustomTool: - """A registered ``github`` custom tool whose handler echoes the auth token.""" - - def github_tool(request: _IssueInput, execute_request, auth_credentials): - """Fetch issue info.""" - return { - "issue_number": request.issue_number, - "token": auth_credentials["access_token"], - } - - return CustomTool(f=github_tool, client=mock_http_client, toolkit="github") - - -@pytest.fixture -def custom_tools(mock_http_client: MagicMock, github_tool: CustomTool) -> CustomTools: - """A ``CustomTools`` registry pre-loaded with ``github_tool``.""" - tools = CustomTools(client=mock_http_client) - tools.custom_tools_registry[github_tool.slug] = github_tool - return tools - - -def test_execute_strips_user_id_from_llm_request( - mock_http_client: MagicMock, custom_tools: CustomTools, github_tool: CustomTool -) -> None: - """``CustomTools.execute`` MUST drop ``user_id`` from the request dict.""" - result = custom_tools.execute( - slug=github_tool.slug, - request={"issue_number": 1, "user_id": "victim-user"}, - user_id="trusted-user", - ) - - assert result == {"issue_number": 1, "token": "trusted-token"} - mock_http_client.connected_accounts.list.assert_called_once_with( - toolkit_slugs=["github"], - user_ids=["trusted-user"], - ) - - -def test_execute_strips_unexpected_fields_via_allowlist( - mock_http_client: MagicMock, custom_tools: CustomTools, github_tool: CustomTool -) -> None: - """The allowlist is durable: any non-declared key is dropped, not just user_id. - - Pins the allowlist behaviour so a future identity-bearing key in the - auth path (``tenant_id``, ``org_id``, ``connected_account_id``, …) - cannot re-introduce CWE-639 by being smuggled through ``request``. - """ - captured: dict = {} - - def echo_tool(request: _IssueInput, execute_request, auth_credentials): - """Echo back the validated request.""" - captured["fields"] = request.model_dump() - return {"ok": True} - - tools = CustomTools(client=mock_http_client) - tool = CustomTool(f=echo_tool, client=mock_http_client, toolkit="github") - tools.custom_tools_registry[tool.slug] = tool - - tools.execute( - slug=tool.slug, - request={ - "issue_number": 1, - "user_id": "victim-user", - "tenant_id": "evil-tenant", - "connected_account_id": "ca_evil", - "extra_garbage": "xyz", - }, - user_id="trusted-user", - ) - - assert captured["fields"] == {"issue_number": 1} - mock_http_client.connected_accounts.list.assert_called_once_with( - toolkit_slugs=["github"], - user_ids=["trusted-user"], - ) - - -def test_execute_keeps_aliases_declared_on_request_model( - mock_http_client: MagicMock, -) -> None: - """Aliases declared on the request model are honoured by the allowlist.""" - - class _AliasedInput(BaseModel): - issue_number: int = Field(alias="issueNumber") - - captured: dict = {} - - def aliased_tool(request: _AliasedInput, execute_request, auth_credentials): - """Echo back the validated request.""" - captured["fields"] = request.model_dump() - return {"ok": True} - - tools = CustomTools(client=mock_http_client) - tool = CustomTool(f=aliased_tool, client=mock_http_client, toolkit="github") - tools.custom_tools_registry[tool.slug] = tool - - tools.execute( - slug=tool.slug, - request={"issueNumber": 5, "user_id": "victim"}, - user_id="trusted-user", - ) - - assert captured["fields"] == {"issue_number": 5} - - -def test_execute_falls_back_to_default_when_user_id_missing( - mock_http_client: MagicMock, custom_tools: CustomTools, github_tool: CustomTool -) -> None: - """If no trusted ``user_id`` is provided, ``execute`` MUST use ``"default"``. - - Specifically, ``execute`` MUST NOT silently use the LLM-supplied - ``user_id`` from ``request`` as a fallback — that would re-introduce - SEC-365 under a different code path. - """ - custom_tools.execute( - slug=github_tool.slug, - request={"issue_number": 1, "user_id": "victim-user"}, - user_id=None, - ) - - mock_http_client.connected_accounts.list.assert_called_once_with( - toolkit_slugs=["github"], - user_ids=["default"], - ) - - -def test_execute_does_not_mutate_caller_request_dict( - custom_tools: CustomTools, github_tool: CustomTool -) -> None: - """Sanitization MUST NOT mutate the caller's ``request`` dict in place.""" - request_dict = {"issue_number": 1, "user_id": "victim-user"} - custom_tools.execute( - slug=github_tool.slug, - request=request_dict, - user_id="trusted-user", - ) - - assert request_dict == {"issue_number": 1, "user_id": "victim-user"} - - -def test_call_raises_typeerror_when_user_id_smuggled_in_kwargs( - github_tool: CustomTool, -) -> None: - """``CustomTool.__call__`` MUST refuse a ``user_id`` kwarg loudly. - - Silent fallbacks expand blast radius: a tool that quietly transacts - against ``"default"`` could end up using a real tenant who happens to - be registered under that id. Failing fast also surfaces prompt-injection - attempts instead of swallowing them. - """ - smuggled_kwargs = {"issue_number": 1, "user_id": "victim-user"} - - with pytest.raises(TypeError, match="user_id"): - github_tool(**smuggled_kwargs) - - -def test_invoke_trusted_uses_explicit_user_id_over_smuggled_one( - mock_http_client: MagicMock, github_tool: CustomTool -) -> None: - """``invoke_trusted``'s explicit ``user_id`` MUST win over a smuggled key. - - The auth-path argument is structurally separate from ``request_kwargs``, - so an LLM-controlled ``user_id`` inside ``request_kwargs`` cannot - override the trusted parameter. This pins the docstring contract. - """ - github_tool.invoke_trusted( - user_id="trusted-user", - request_kwargs={"issue_number": 7, "user_id": "victim-user"}, - ) - - mock_http_client.connected_accounts.list.assert_called_once_with( - toolkit_slugs=["github"], - user_ids=["trusted-user"], - ) - - -def test_execute_unknown_slug_raises(custom_tools: CustomTools) -> None: - """Sanity check: unknown slugs still surface ``NotFoundError``.""" - with pytest.raises(NotFoundError): - custom_tools.execute( - slug="DOES_NOT_EXIST", - request={"foo": "bar"}, - user_id="trusted-user", - ) - - -def test_tools_execute_e2e_strips_user_id_through_full_stack( - mock_http_client: MagicMock, custom_tools: CustomTools, github_tool: CustomTool -) -> None: - """End-to-end check through ``Tools.execute`` (the public SDK entry point). - - ``Tools._execute_tool`` routes custom-tool calls into - ``CustomTools.execute``. The modifier-hook block can also overwrite - ``arguments`` and ``user_id`` from a ``processed_params`` dict, so we - pin that the sanitization still applies after that path. - """ - from composio.core.models.tools import Tools - - provider = MagicMock() - provider.name = "test" - - tools = Tools(client=mock_http_client, provider=provider) - tools._custom_tools = custom_tools - - response = tools.execute( - slug=github_tool.slug, - arguments={"issue_number": 1, "user_id": "victim-user"}, - user_id="trusted-user", - ) - - assert response["successful"] is True - assert response["data"]["issue_number"] == 1 - assert response["data"]["token"] == "trusted-token" - mock_http_client.connected_accounts.list.assert_called_once_with( - toolkit_slugs=["github"], - user_ids=["trusted-user"], - ) diff --git a/python/tests/test_imports.py b/python/tests/test_imports.py index b60ada9ee..d991ee230 100644 --- a/python/tests/test_imports.py +++ b/python/tests/test_imports.py @@ -38,7 +38,6 @@ def test_import_all_public_exports(): def test_import_types(): """Test importing types from different modules.""" from composio.types import ( - ExecuteRequestFn, Modifiers, Tool, ToolExecuteParams, @@ -58,7 +57,6 @@ def test_import_types(): assert TToolCollection is not None assert ToolExecuteParams is not None assert ToolExecutionResponse is not None - assert ExecuteRequestFn is not None assert TriggerEvent is not None assert Modifiers is not None assert auth_scheme is not None diff --git a/python/tests/test_provider.py b/python/tests/test_provider.py index 0bbe443b0..20b895f9c 100644 --- a/python/tests/test_provider.py +++ b/python/tests/test_provider.py @@ -836,43 +836,6 @@ class TestLangchainReservedKeywords: class TestProviderEdgeCases: """Test edge cases and error handling for providers.""" - def test_execute_tool_with_custom_tool(self): - """Test that execute_tool works with custom tools.""" - mock_client = Mock() - from composio.core.provider._openai import OpenAIProvider - - provider = OpenAIProvider() - - tools = Tools( - client=mock_client, - provider=provider, - toolkit_versions={"custom": "12012025_00"}, - ) - - custom_tool = create_mock_tool("CUSTOM_TOOL", "custom", "12012025_00") - - mock_custom_tool = Mock() - mock_custom_tool.info = custom_tool - - def mock_get(slug): - return mock_custom_tool if slug == "CUSTOM_TOOL" else None - - tools._custom_tools.get = Mock(side_effect=mock_get) - - def mock_execute(slug, request, user_id): - return {"custom_result": "success", "slug": slug} - - tools._custom_tools.execute = Mock(side_effect=mock_execute) - - result = provider.execute_tool( - slug="CUSTOM_TOOL", - arguments={"param": "value"}, - user_id="test-user", - ) - - assert result["successful"] is True - assert result["data"]["custom_result"] == "success" - def test_execute_tool_with_none_parameters(self): """Test that execute_tool works when optional parameters are None.""" mock_client = Mock() diff --git a/python/tests/test_tool_execution.py b/python/tests/test_tool_execution.py index d0e8c6b5d..b181ed1f0 100644 --- a/python/tests/test_tool_execution.py +++ b/python/tests/test_tool_execution.py @@ -715,59 +715,6 @@ class TestToolExecution: assert call_args.kwargs["user_id"] == "user-123" assert call_args.kwargs["text"] == "Additional context" - def test_execute_custom_tool(self): - """Test that execute works with custom tools.""" - # Mock client and provider - mock_client = Mock() - mock_provider = Mock() - mock_provider.name = "test_provider" - - # Create Tools instance - tools = Tools( - client=mock_client, - provider=mock_provider, - toolkit_versions={"custom": "20251201_01"}, - ) - - # Create a mock custom tool with proper structure - custom_tool_info = self.create_mock_tool("CUSTOM_TOOL", "custom") - custom_tool_info.input_parameters = {"type": "object", "properties": {}} - - # Mock the custom tool registry - mock_custom_tool = Mock() - mock_custom_tool.info = custom_tool_info - tools._custom_tools.custom_tools_registry = {"CUSTOM_TOOL": mock_custom_tool} - - # Mock the get method to return the tool - def mock_get(slug): - return mock_custom_tool if slug == "CUSTOM_TOOL" else None - - tools._custom_tools.get = Mock(side_effect=mock_get) - - # Mock the execute method of custom tool - def mock_execute(slug, request, user_id): - return {"custom_result": "success"} - - tools._custom_tools.execute = Mock(side_effect=mock_execute) - - # Execute the custom tool - result = tools.execute( - slug="CUSTOM_TOOL", - arguments={"param": "value"}, - user_id="user-123", - ) - - # Verify execution succeeded - assert result["successful"] is True - assert result["data"]["custom_result"] == "success" - - # Verify custom tool execute was called - tools._custom_tools.execute.assert_called_once_with( - slug="CUSTOM_TOOL", - request={"param": "value"}, - user_id="user-123", - ) - def test_execute_with_modifiers_before_execute(self): """Test that execute applies before_execute modifiers correctly.""" from composio.core.models._modifiers import before_execute diff --git a/uv.lock b/uv.lock index 5744e591b..066a09569 100644 --- a/uv.lock +++ b/uv.lock @@ -592,7 +592,7 @@ wheels = [ [[package]] name = "composio" -version = "0.13.1" +version = "0.14.0" source = { editable = "python" } dependencies = [ { name = "composio-client" }, @@ -649,7 +649,7 @@ dev = [ [[package]] name = "composio-anthropic" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/anthropic" } dependencies = [ { name = "anthropic" }, @@ -681,7 +681,7 @@ wheels = [ [[package]] name = "composio-crewai" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/crewai" } dependencies = [ { name = "composio" }, @@ -696,7 +696,7 @@ requires-dist = [ [[package]] name = "composio-gemini" -version = "0.13.1" +version = "0.14.0" source = { virtual = "python/providers/gemini" } dependencies = [ { name = "composio" }, @@ -711,7 +711,7 @@ requires-dist = [ [[package]] name = "composio-google" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/google" } dependencies = [ { name = "composio" }, @@ -726,7 +726,7 @@ requires-dist = [ [[package]] name = "composio-google-adk" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/google_adk" } dependencies = [ { name = "composio" }, @@ -741,7 +741,7 @@ requires-dist = [ [[package]] name = "composio-langchain" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/langchain" } dependencies = [ { name = "composio" }, @@ -756,7 +756,7 @@ requires-dist = [ [[package]] name = "composio-openai" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/openai" } dependencies = [ { name = "composio" }, @@ -771,7 +771,7 @@ requires-dist = [ [[package]] name = "composio-openai-agents" -version = "0.13.1" +version = "0.14.0" source = { editable = "python/providers/openai_agents" } dependencies = [ { name = "composio" },