diff --git a/pyproject.toml b/pyproject.toml index ac43b3b36..c0752c89f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.14.16" +version = "0.14.17" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath>=2.13.7, <2.14.0", + "uipath>=2.13.16, <2.14.0", "uipath-core>=0.5.29, <0.6.0", "uipath-platform>=0.2.12, <0.3.0", "uipath-runtime>=0.12.5, <0.13.0", diff --git a/src/uipath_langchain/agent/exceptions/exceptions.py b/src/uipath_langchain/agent/exceptions/exceptions.py index 65ebbaf81..5ac66e3d7 100644 --- a/src/uipath_langchain/agent/exceptions/exceptions.py +++ b/src/uipath_langchain/agent/exceptions/exceptions.py @@ -50,6 +50,7 @@ class AgentRuntimeErrorCode(str, Enum): INVALID_ATTACHMENT_ID = "INVALID_ATTACHMENT_ID" OUTPUT_VALIDATION_ERROR = "OUTPUT_VALIDATION_ERROR" INVALID_STATIC_ARGUMENT = "INVALID_STATIC_ARGUMENT" + INVALID_INPUT_ARGUMENT = "INVALID_INPUT_ARGUMENT" FILE_ERROR = "FILE_ERROR" diff --git a/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py b/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py new file mode 100644 index 000000000..2035bff47 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py @@ -0,0 +1,390 @@ +"""Internal HTTP request tool. + +Issues an outbound HTTP request to a caller-provided URL. The tool's arguments +(``url``/``method``/``headers``/``params``/``body``/``timeout``) come from the +resource's input schema authored in the agent definition, mirroring the +analyze-files tool; each may be pinned to a static value, bound to an agent +input, or left for the LLM to infer through the generic ``argument_properties`` +mechanism shared by all structured tools. + +Requests that resolve to private, loopback, link-local, or cloud-metadata +addresses are rejected to guard against SSRF; the check runs on every request, +including redirect hops. +""" + +import asyncio +import ipaddress +import json +import re +import socket +from typing import Any +from urllib.parse import urlparse + +import httpx +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import StructuredTool +from pydantic import BaseModel +from uipath._utils._ssl_context import get_httpx_client_kwargs +from uipath.agent.models.agent import AgentInternalToolResourceConfig +from uipath.eval.mocks import mockable +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) +from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( + create_model, + create_output_model, +) +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) +from uipath_langchain.agent.tools.utils import sanitize_tool_name + +# HTTP methods the http-request tool supports. +HTTP_REQUEST_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] + +# Default per-request timeout (seconds) applied when neither the configured +# argument properties nor the LLM provide one. +HTTP_REQUEST_DEFAULT_TIMEOUT_SECONDS = 30.0 + +# Fixed output schema for the http-request tool. +HTTP_REQUEST_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "statusCode": { + "type": "integer", + "description": "HTTP status code of the response.", + }, + "headers": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": "Response headers.", + }, + "body": { + "type": "string", + "description": "Response body as text.", + }, + }, + "required": ["statusCode", "headers", "body"], +} + +# Maximum number of redirects to follow. Each hop is re-validated by the SSRF +# guard, so this only bounds how long a redirect chain may run. +_MAX_REDIRECTS = 5 + +# Matches a leading URI scheme (e.g. ``https://``, ``ftp://``). Used to detect +# whether the caller supplied a scheme at all. +_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://") + + +def _normalize_url(url: str) -> str: + """Default to ``https`` when the caller omits a scheme. + + ``"google.com"`` becomes ``"https://google.com"``; a scheme-relative + ``"//google.com"`` becomes ``"https://google.com"``. A URL that already + carries an explicit scheme is returned unchanged — including non-HTTP(S) + schemes, which the SSRF guard then rejects. + """ + url = url.strip() + if url.startswith("//"): + return f"https:{url}" + if not _SCHEME_RE.match(url): + return f"https://{url}" + return url + + +def _has_header(headers: dict[str, str] | None, name: str) -> bool: + """Case-insensitive check for whether a header is already present.""" + if not headers: + return False + lowered = name.lower() + return any(key.lower() == lowered for key in headers) + + +def _is_json_object_body(text: str) -> bool: + """Whether a string body is a JSON object or array (not a bare scalar). + + Restricting to object/array avoids mislabeling plain-text bodies that happen + to be valid JSON scalars (e.g. ``"123"`` or ``"true"``) as JSON. + """ + try: + parsed = json.loads(text) + except ValueError: + return False + return isinstance(parsed, (dict, list)) + + +def _pairs_to_dict(pairs: list[Any]) -> dict[str, Any]: + """Fold a list of ``{name, value}`` items into a dict. + + Headers and query params are modeled as arrays of name/value pairs rather + than an open map, so the generated tool schema stays compatible with + providers that reject ``additionalProperties`` (notably Gemini, which would + otherwise see a property-less object). Each item may be a dict or a pydantic + model (the validated form); later duplicate names win. + """ + result: dict[str, Any] = {} + for item in pairs: + if isinstance(item, BaseModel): + item = item.model_dump() + if isinstance(item, dict) and item.get("name") is not None: + result[str(item["name"])] = item.get("value") + return result + + +def _stringify_mapping(mapping: Any) -> dict[str, str] | None: + """Coerce a header/param argument to a ``dict[str, str]`` for httpx. + + Accepts the validated forms the argument can take — a list of + ``{name, value}`` pairs (the canonical, provider-portable shape), a pydantic + model, or a plain dict — and returns string-valued entries (httpx headers + must be strings; query params are cleanest as strings). Booleans render as + lowercase ``true``/``false``. Returns ``None`` for an empty/missing value. + """ + if isinstance(mapping, BaseModel): + mapping = mapping.model_dump() + if isinstance(mapping, list): + mapping = _pairs_to_dict(mapping) + if not mapping or not isinstance(mapping, dict): + return None + result: dict[str, str] = {} + for key, value in mapping.items(): + if isinstance(value, bool): + result[str(key)] = "true" if value else "false" + elif isinstance(value, str): + result[str(key)] = value + else: + result[str(key)] = str(value) + return result or None + + +def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Whether an IP is non-public (private, loopback, link-local, ...). + + Link-local covers the cloud-metadata address ``169.254.169.254``. + """ + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ) + + +def _blocked_host_error(detail: str) -> AgentRuntimeError: + return AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="Request was blocked", + detail=detail, + category=UiPathErrorCategory.USER, + ) + + +async def _assert_public_url(url: str) -> None: + """Reject a URL whose host is missing, non-HTTP(S), or internal. + + Resolves the host and fails if *any* resolved address is non-public, so a + hostname that maps to an internal IP cannot slip through. + """ + parsed = urlparse(url) + + if parsed.scheme not in ("http", "https"): + raise _blocked_host_error( + f"Unsupported URL scheme {parsed.scheme!r}; only http and https are allowed." + ) + + host = parsed.hostname + if not host: + raise _blocked_host_error(f"URL {url!r} has no host.") + + # A bare IP literal in the URL still needs checking. + try: + literal_ip = ipaddress.ip_address(host) + except ValueError: + literal_ip = None + if literal_ip is not None and _is_blocked_ip(literal_ip): + raise _blocked_host_error(f"Host {host!r} resolves to a non-public address.") + + try: + infos = await asyncio.get_running_loop().getaddrinfo( + host, parsed.port, type=socket.SOCK_STREAM + ) + except socket.gaierror as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="Host could not be resolved", + detail=f"Could not resolve host {host!r}: {e}", + category=UiPathErrorCategory.USER, + ) from e + + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if _is_blocked_ip(ip): + raise _blocked_host_error( + f"Host {host!r} resolves to non-public address {ip}." + ) + + +async def _validate_request_hook(request: httpx.Request) -> None: + """httpx request hook that runs the SSRF guard on every hop.""" + await _assert_public_url(str(request.url)) + + +def create_http_request_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create the http-request internal tool from resource configuration. + + ``llm`` is accepted for signature parity with the other internal tool + factories but is unused: the tool issues a plain HTTP request. + """ + tool_name = sanitize_tool_name(resource.name) + input_model = create_model(resource.input_schema) + output_model = create_output_model(HTTP_REQUEST_OUTPUT_SCHEMA, resource.name) + + @mockable( + name=resource.name, + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=output_model.model_json_schema(), + example_calls=[], # Examples cannot be provided for internal tools + ) + async def http_request_tool_fn(**kwargs: Any) -> dict[str, Any]: + url = kwargs.get("url") + if not url: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_INPUT_ARGUMENT, + title="Missing required argument", + detail="Argument 'url' is required.", + category=UiPathErrorCategory.USER, + ) + # Default to https when no scheme is given (e.g. "google.com"). + url = _normalize_url(url) + + method = (kwargs.get("method") or "GET").upper() + if method not in HTTP_REQUEST_METHODS: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_INPUT_ARGUMENT, + title="Unsupported HTTP method", + detail=( + f"Unsupported HTTP method {method!r}; expected one of " + f"{', '.join(HTTP_REQUEST_METHODS)}." + ), + category=UiPathErrorCategory.USER, + ) + + headers = _stringify_mapping(kwargs.get("headers")) + params = _stringify_mapping(kwargs.get("params")) + + timeout = kwargs.get("timeout") + if timeout is None: + timeout = HTTP_REQUEST_DEFAULT_TIMEOUT_SECONDS + elif ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or timeout <= 0 + ): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_INPUT_ARGUMENT, + title="Invalid timeout", + detail=( + "Argument 'timeout' must be a positive number of seconds; " + f"got {timeout!r}." + ), + category=UiPathErrorCategory.USER, + ) + + # A dict/list body is sent as JSON; anything else is sent as raw content. + # A validated object body arrives as a pydantic model, not a dict. + request_kwargs: dict[str, Any] = {} + body = kwargs.get("body") + if isinstance(body, BaseModel): + body = body.model_dump() + if body is not None: + if isinstance(body, (dict, list)): + request_kwargs["json"] = body + elif isinstance(body, (str, bytes)): + request_kwargs["content"] = body + # A JSON object/array string is very likely meant as JSON, but + # httpx does not set Content-Type for raw content. Default it + # when the caller did not set one, so callers aren't forced to + # remember the header. + if ( + isinstance(body, str) + and _is_json_object_body(body) + and not _has_header(headers, "content-type") + ): + headers = {**(headers or {}), "Content-Type": "application/json"} + else: + request_kwargs["content"] = str(body) + + # Build from get_httpx_client_kwargs() (enforced SSL/proxy config), but + # drop the merged UiPath platform headers so internal licensing context + # is never leaked to an arbitrary third-party host. + client_kwargs = get_httpx_client_kwargs() + client_kwargs.pop("headers", None) + + try: + async with httpx.AsyncClient( + event_hooks={"request": [_validate_request_hook]}, + max_redirects=_MAX_REDIRECTS, + **client_kwargs, + ) as client: + response = await client.request( + method, + url, + headers=headers, + params=params, + timeout=timeout, + **request_kwargs, + ) + except AgentRuntimeError: + raise + except httpx.TimeoutException as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="HTTP request timed out", + detail=f"Request to {url!r} timed out after {timeout}s: {e}", + category=UiPathErrorCategory.USER, + ) from e + except httpx.HTTPError as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="HTTP request failed", + detail=f"Request to {url!r} failed: {e}", + category=UiPathErrorCategory.USER, + ) from e + + # Non-2xx responses are returned to the agent rather than raised, so it + # can react to the status code. + return { + "statusCode": response.status_code, + "headers": dict(response.headers), + "body": response.text, + } + + # Import here to avoid circular dependency + from uipath_langchain.agent.wrappers import get_job_attachment_wrapper + + job_attachment_wrapper = get_job_attachment_wrapper(output_type=output_model) + + tool = StructuredToolWithArgumentProperties( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=http_request_tool_fn, + output_type=output_model, + argument_properties=resource.argument_properties, + metadata={ + "tool_type": resource.type.lower(), + "display_name": tool_name, + "args_schema": input_model, + "output_schema": output_model, + }, + ) + tool.set_tool_wrappers(awrapper=job_attachment_wrapper) + return tool diff --git a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py index 5a8410675..19891077c 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -6,6 +6,9 @@ Supported Internal Tools: - ANALYZE_FILES: Tool for analyzing file contents and extracting information + - DEEP_RAG: Tool for deep retrieval-augmented generation over documents + - BATCH_TRANSFORM: Tool for batch transformation of document data + - HTTP_REQUEST: Tool for issuing outbound HTTP requests to a given URL Example: >>> from uipath.agent.models.agent import AgentInternalToolResourceConfig @@ -29,6 +32,7 @@ from .analyze_files_tool import create_analyze_file_tool from .batch_transform_tool import create_batch_transform_tool from .deeprag_tool import create_deeprag_tool +from .http_request_tool import create_http_request_tool _INTERNAL_TOOL_HANDLERS: dict[ AgentInternalToolType, @@ -37,6 +41,7 @@ AgentInternalToolType.ANALYZE_FILES: create_analyze_file_tool, AgentInternalToolType.DEEP_RAG: create_deeprag_tool, AgentInternalToolType.BATCH_TRANSFORM: create_batch_transform_tool, + AgentInternalToolType.HTTP_REQUEST: create_http_request_tool, } diff --git a/tests/agent/tools/internal_tools/test_http_request_tool.py b/tests/agent/tools/internal_tools/test_http_request_tool.py new file mode 100644 index 000000000..f3e8ecd1a --- /dev/null +++ b/tests/agent/tools/internal_tools/test_http_request_tool.py @@ -0,0 +1,276 @@ +"""Tests for http_request_tool.py module. + +These tests exercise the tool only through its public surface (the created +tool's ``coroutine``/``ainvoke``), never its module-private helpers. Requests +target a literal public IP (``8.8.8.8``) so the real SSRF guard runs and passes +without any DNS lookup — no need to mock internal functions. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import BaseModel +from uipath.agent.models.agent import ( + AgentInternalHttpRequestToolProperties, + AgentInternalToolResourceConfig, +) + +from uipath_langchain.agent.exceptions import AgentRuntimeError +from uipath_langchain.agent.tools.internal_tools.http_request_tool import ( + create_http_request_tool, +) +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) + +# A literal public IP: the SSRF guard treats it as public, and getaddrinfo on a +# numeric host resolves locally, so tests need no network and no patching. +PUBLIC_HOST = "8.8.8.8" +BASE_URL = f"https://{PUBLIC_HOST}" + +# Patch mockable to a passthrough for every test in this module, matching the +# convention in the sibling internal-tool tests. +pytestmark = pytest.mark.usefixtures("_passthrough_mockable") + + +@pytest.fixture +def _passthrough_mockable(): + with patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool.mockable", + lambda **kwargs: lambda f: f, + ): + yield + + +@pytest.fixture +def mock_llm(): + return AsyncMock() + + +@pytest.fixture +def resource_config(): + # The input schema is authored in the agent definition (like analyze-files); + # the tool reads these fields from kwargs rather than injecting them. + # Headers/params are arrays of name/value pairs (not open maps) so the + # generated tool schema stays compatible with providers that reject + # additionalProperties (e.g. Gemini); body is a plain string. + pair_list = { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"}, + }, + "required": ["name", "value"], + }, + } + input_schema = { + "type": "object", + "properties": { + "url": {"type": "string"}, + "method": {"type": "string"}, + "headers": pair_list, + "params": pair_list, + "body": {"type": "string"}, + "timeout": {"type": "number"}, + }, + "required": ["url"], + } + return AgentInternalToolResourceConfig( + name="http_request", + description="Make an HTTP request", + input_schema=input_schema, + output_schema={}, + properties=AgentInternalHttpRequestToolProperties(), + ) + + +@pytest.fixture +def tool(resource_config, mock_llm): + return create_http_request_tool(resource_config, mock_llm) + + +class TestCreateHttpRequestTool: + def test_tool_creation_and_schema(self, tool): + """Tool is created with the canonical input fields and fixed output fields.""" + assert isinstance(tool, StructuredToolWithArgumentProperties) + assert tool.name == "http_request" + assert tool.description == "Make an HTTP request" + + args_schema = tool.args_schema + assert isinstance(args_schema, type) and issubclass(args_schema, BaseModel) + input_fields = set(args_schema.model_fields.keys()) + assert {"url", "method", "headers", "params", "body", "timeout"} <= input_fields + + output_fields = set(tool.output_type.model_fields.keys()) + assert output_fields == {"statusCode", "headers", "body"} + + async def test_get_request_happy_path(self, tool, httpx_mock): + """A GET request returns statusCode/headers/body; method is normalized.""" + httpx_mock.add_response( + url=f"{BASE_URL}/items?page=1", + method="GET", + status_code=200, + headers={"Content-Type": "application/json"}, + text='{"ok": true}', + ) + + result = await tool.ainvoke( + { + "url": f"{BASE_URL}/items", + "method": "get", # lowercase -> normalized to GET + "params": [{"name": "page", "value": "1"}], + } + ) + + assert result["statusCode"] == 200 + assert result["headers"]["content-type"] == "application/json" + assert result["body"] == '{"ok": true}' + + async def test_json_string_body_gets_default_content_type(self, tool, httpx_mock): + """A JSON object/array string body is sent verbatim, with an auto Content-Type.""" + httpx_mock.add_response( + url=f"{BASE_URL}/create", method="POST", status_code=201, text="created" + ) + + result = await tool.ainvoke( + { + "url": f"{BASE_URL}/create", + "method": "POST", + "body": '{"name": "widget", "qty": 3}', # no Content-Type header set + } + ) + + assert result["statusCode"] == 201 + request = httpx_mock.get_requests()[0] + assert request.headers["content-type"] == "application/json" + assert request.read() == b'{"name": "widget", "qty": 3}' # not re-serialized + + async def test_explicit_content_type_is_not_overridden(self, tool, httpx_mock): + """A caller-set Content-Type wins over the JSON default.""" + httpx_mock.add_response(method="POST", status_code=200, text="ok") + + await tool.ainvoke( + { + "url": f"{BASE_URL}/create", + "method": "POST", + "headers": [{"name": "content-type", "value": "text/plain"}], + "body": '{"a": 1}', + } + ) + + assert httpx_mock.get_requests()[0].headers["content-type"] == "text/plain" + + async def test_non_json_string_body_has_no_default_content_type( + self, tool, httpx_mock + ): + """A non-JSON string body is sent as-is with no Content-Type inferred.""" + httpx_mock.add_response(method="PUT", status_code=200, text="ok") + + await tool.ainvoke( + {"url": f"{BASE_URL}/raw", "method": "PUT", "body": "raw-payload"} + ) + + request = httpx_mock.get_requests()[0] + assert request.read() == b"raw-payload" + assert "content-type" not in request.headers + + async def test_scalar_json_body_is_not_labeled_json(self, tool, httpx_mock): + """A bare JSON scalar string (e.g. '123') is not treated as a JSON body.""" + httpx_mock.add_response(method="POST", status_code=200, text="ok") + + await tool.ainvoke({"url": f"{BASE_URL}/x", "method": "POST", "body": "123"}) + + assert "content-type" not in httpx_mock.get_requests()[0].headers + + async def test_header_and_param_pairs_are_folded_into_request( + self, tool, httpx_mock + ): + """Name/value pair lists are folded into httpx headers and query params. + + Pairs go through args-schema validation (arriving as pydantic models, as + in the agent graph), exercising the list-of-pairs -> dict conversion. + """ + httpx_mock.add_response(method="GET", status_code=200, text="ok") + + result = await tool.ainvoke( + { + "url": f"{BASE_URL}/x", + "params": [ + {"name": "a", "value": "3"}, + {"name": "b", "value": "5"}, + ], + "headers": [{"name": "X-Count", "value": "7"}], + } + ) + + assert result["statusCode"] == 200 + request = httpx_mock.get_requests()[0] + assert dict(request.url.params) == {"a": "3", "b": "5"} + assert request.headers["x-count"] == "7" + + async def test_non_2xx_is_returned_not_raised(self, tool, httpx_mock): + """A 4xx/5xx response is returned to the agent rather than raised.""" + httpx_mock.add_response(status_code=404, text="not found") + + result = await tool.ainvoke({"url": f"{BASE_URL}/missing"}) + + assert result["statusCode"] == 404 + assert result["body"] == "not found" + + async def test_schemeless_url_defaults_to_https(self, tool, httpx_mock): + """A URL without a scheme is requested over https.""" + httpx_mock.add_response(url=BASE_URL, method="GET", status_code=200, text="ok") + + result = await tool.ainvoke({"url": PUBLIC_HOST}) # no scheme + + assert result["statusCode"] == 200 + assert str(httpx_mock.get_requests()[0].url) == BASE_URL + + async def test_missing_url_raises(self, mock_llm): + """The tool's own guard rejects a missing url. + + Uses a schema that does not mark ``url`` required, so args validation + passes and the tool's defensive check is what raises (a schema that + requires ``url`` would be rejected earlier, by validation). + """ + resource = AgentInternalToolResourceConfig( + name="http_request", + description="Make an HTTP request", + input_schema={"type": "object", "properties": {"url": {"type": "string"}}}, + output_schema={}, + properties=AgentInternalHttpRequestToolProperties(), + ) + tool = create_http_request_tool(resource, mock_llm) + with pytest.raises(AgentRuntimeError, match="Argument 'url' is required"): + await tool.ainvoke({}) + + async def test_invalid_method_raises(self, tool): + with pytest.raises(AgentRuntimeError, match="Unsupported HTTP method"): + await tool.ainvoke({"url": f"{BASE_URL}/x", "method": "FETCH"}) + + @pytest.mark.parametrize("bad_timeout", [-1, 0, -0.5]) + async def test_non_positive_timeout_raises(self, tool, bad_timeout): + with pytest.raises(AgentRuntimeError, match="must be a positive number"): + await tool.ainvoke({"url": f"{BASE_URL}/x", "timeout": bad_timeout}) + + @pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/admin", + "http://localhost:8080/", + "http://10.0.0.5/", + "http://169.254.169.254/latest/meta-data/", + "http://metadata.google.internal/", + f"ftp://{PUBLIC_HOST}/file", + ], + ) + async def test_ssrf_blocked_targets_are_rejected(self, tool, url): + """Internal/metadata/non-http targets are rejected before any request. + + No response is registered because the request never leaves the SSRF + guard. + """ + with pytest.raises(AgentRuntimeError): + await tool.ainvoke({"url": url}) diff --git a/uv.lock b/uv.lock index a90039156..8f9c5a1c5 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-20T22:41:41.618612Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -4453,7 +4453,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.7" +version = "2.13.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4477,9 +4477,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/76/f57747721f7e6386bf277f34e4ccc5d34f1e58a8c31080a4ed449df8b614/uipath-2.13.7.tar.gz", hash = "sha256:379af967a9a302b3521938030ae41bf53c21a34a68aa6354416caf2b76cecb2f", size = 4500489, upload-time = "2026-07-08T15:57:51.3Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/17036d803ea11745af4c2cc1d6c8c6ec1b2d3d454eec842f220930a48f4e/uipath-2.13.16.tar.gz", hash = "sha256:d8977972ab29c7eac22fcfa044186701ba2961d746fd38b06f43c665d8f8cf5f", size = 4527477, upload-time = "2026-07-24T08:09:45.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/1b/9c4f095cb4e87eaded00ea9eb022a4ea51f663b961124e6dccb27bd7fc82/uipath-2.13.7-py3-none-any.whl", hash = "sha256:145881ae320b9d936252766f1cb575ed40601181b1f313b5816fc43653d92ad5", size = 418876, upload-time = "2026-07-08T15:57:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/4e/08/aec381f035d00d2c0e1bde410a1d79c9c61d697004d1a849d2be373e3486/uipath-2.13.16-py3-none-any.whl", hash = "sha256:99909c8dcbbb0c2b08710f2d55c55b6b54a4473be6df3c4daab8442e12a4b860", size = 431522, upload-time = "2026-07-24T08:09:43.486Z" }, ] [[package]] @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.16" +version = "0.14.17" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4578,7 +4578,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "rdflib", specifier = ">=7.0.0,<8.0.0" }, - { name = "uipath", specifier = ">=2.13.7,<2.14.0" }, + { name = "uipath", specifier = ">=2.13.16,<2.14.0" }, { name = "uipath-core", specifier = ">=0.5.29,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.17.1,<1.18.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.17.1,<1.18.0" },