diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d2c8eba69..a674cbce1 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.28" +version = "0.2.29" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py index 0f6a16209..7cc8c5235 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -47,6 +47,7 @@ BYO_VALIDATOR_TYPE, BuiltInValidatorGuardrail, EnumListParameterValue, + GuardrailAttachment, GuardrailType, MapEnumParameterValue, ) @@ -56,6 +57,7 @@ "GuardrailsService", # Guardrail models "BYO_VALIDATOR_TYPE", + "GuardrailAttachment", "BuiltInValidatorGuardrail", "GuardrailType", "GuardrailValidationResultType", diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py index b73d810e7..ee00805b6 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -17,7 +17,35 @@ from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec from ..errors import EnrichedException -from .guardrails import BYO_VALIDATOR_TYPE, BuiltInValidatorGuardrail +from .guardrails import ( + BYO_VALIDATOR_TYPE, + BuiltInValidatorGuardrail, + GuardrailAttachment, +) + +#: Timeout for a validate call carrying attachments. The backend fetches and decodes each +#: file inside the request, which the default 30s client timeout does not allow for. +_ATTACHMENT_VALIDATE_TIMEOUT_SECONDS = 60.0 + + +def _redact_attachment_urls(inputs: dict[str, Any]) -> dict[str, Any]: + """Strip SAS urls from the traced inputs of ``evaluate_guardrail``. + + ``@traced`` records a function's arguments on the span by default. An attachment + ``url`` is a short-lived SAS credential and must never reach telemetry, so replace + it and keep the rest (guardrail, input, attachment identity) intact. + """ + attachments = inputs.get("attachments") + if not isinstance(attachments, list): + return inputs + redacted = [] + for attachment in attachments: + if isinstance(attachment, dict) and "url" in attachment: + redacted.append({**attachment, "url": ""}) + else: + redacted.append(attachment) + return {**inputs, "attachments": redacted} + # x-uipath-traceparent-id header format: {version}-{trace_id}-{span_id}[-{trace_flags}] # Based on W3C traceparent but allows 16- or 32-hex span IDs. @@ -97,17 +125,25 @@ def _parse_result(result_str: str) -> GuardrailValidationResultType: # Fallback to validation_failed if unknown return GuardrailValidationResultType.VALIDATION_FAILED - @traced("evaluate_guardrail", run_type="uipath") + @traced( + "evaluate_guardrail", run_type="uipath", input_processor=_redact_attachment_urls + ) def evaluate_guardrail( self, input_data: str | dict[str, Any], guardrail: BuiltInValidatorGuardrail, + *, + attachments: list[GuardrailAttachment] | None = None, ) -> GuardrailValidationResult: """Validate input text using the provided guardrail. Args: input_data: The text or structured data to validate. Dictionaries will be converted to a string before validation. guardrail: A guardrail instance used for validation. + attachments: Files attached to the run that the guardrail may inspect, so a + validator can evaluate a file's contents rather than only its metadata. + Which validators can use them, and which file types are readable, is + decided server-side. Omitted from the request body when empty. Returns: GuardrailValidationResult: The outcome of the guardrail evaluation. @@ -127,6 +163,8 @@ def evaluate_guardrail( "BYO (Bring Your Own) guardrails require byo_validator_name." ) payload["byoValidatorName"] = guardrail.byo_validator_name + if attachments: + payload["attachments"] = [a.model_dump(by_alias=True) for a in attachments] spec = RequestSpec( method="POST", endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), @@ -147,13 +185,22 @@ def evaluate_guardrail( **source_headers, **header_job_key(), } + # The default client timeout is 30s (common/_http_config.py). A validate call + # carrying attachments waits for the backend to fetch and decode each one, so give + # it more room. RequestSpec.timeout exists but is never forwarded, so pass it here. + request_kwargs: dict[str, Any] = { + "json": spec.json, + "headers": request_headers, + } + if attachments: + request_kwargs["timeout"] = _ATTACHMENT_VALIDATE_TIMEOUT_SECONDS + span_id = None try: response = self.request( spec.method, url=spec.endpoint, - json=spec.json, - headers=request_headers, + **request_kwargs, ) span_id = self._extract_span_id_from_traceparent( response.headers.get("x-uipath-traceparent-id") diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index dace18019..8b860837f 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -96,6 +96,30 @@ class BuiltInValidatorGuardrail(BaseGuardrail): model_config = ConfigDict(populate_by_name=True, extra="allow") +class GuardrailAttachment(BaseModel): + """A file attached to the run that a guardrail may inspect. + + Passed to [`GuardrailsService.evaluate_guardrail`][uipath.platform.guardrails.GuardrailsService.evaluate_guardrail] + so the guardrails backend can read the file's contents rather than only its metadata. + + Attributes: + id: The job attachment id, as a string UUID. Used by the backend as an + extraction cache key and for trace correlation. + file_name: Original file name, shown to a judge model so it can name the + offending file. + mime_type: Original mime type. The backend decides what it can inspect. + url: A short-lived SAS URL resolved by the runtime. This is a **credential**: + never log it, never put it on a span. + """ + + id: str + file_name: str = Field(alias="fileName") + mime_type: str = Field(alias="mimeType") + url: str + + model_config = ConfigDict(populate_by_name=True) + + class GuardrailType(str, Enum): """Guardrail type enumeration.""" diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index d20d531a7..a85f1b515 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -14,6 +14,7 @@ from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, EnumListParameterValue, + GuardrailAttachment, GuardrailsService, MapEnumParameterValue, ) @@ -897,3 +898,197 @@ def test_invalid_format(self) -> None: assert ( GuardrailsService._extract_span_id_from_traceparent("not-valid") is None ) + + +_VALIDATE_PATH = "/agentsruntime_/api/execution/guardrails/validate" +_ATTACHMENT_ID = "7f2c1e44-0b3a-4a1e-9d55-2f9a1c3b8e10" + + +def _judge_guardrail() -> BuiltInValidatorGuardrail: + return BuiltInValidatorGuardrail( + id="g1", + name="Injection check", + description="Test judge", + enabled_for_evals=True, + selector=GuardrailSelector(scopes=[GuardrailScope.AGENT]), + guardrail_type="builtInValidator", + validator_type="llm_as_judge", + validator_parameters=[], + ) + + +def _attachment() -> GuardrailAttachment: + return GuardrailAttachment( + id=_ATTACHMENT_ID, + file_name="Tickets.csv", + mime_type="text/csv", + url="https://acct.blob.core.windows.net/c/Tickets.csv?sig=x", + ) + + +class TestGuardrailAttachments: + """evaluate_guardrail forwards attachment references to the validate API.""" + + def test_attachments_are_sent_with_camel_case_aliases( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail( + "see attached", _judge_guardrail(), attachments=[_attachment()] + ) + + body = json.loads(httpx_mock.get_requests()[0].content) + assert body["attachments"] == [ + { + "id": _ATTACHMENT_ID, + "fileName": "Tickets.csv", + "mimeType": "text/csv", + "url": "https://acct.blob.core.windows.net/c/Tickets.csv?sig=x", + } + ] + + def test_attachments_key_is_absent_when_not_supplied( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """An older backend must see a byte-identical body to today.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("no files here", _judge_guardrail()) + + assert "attachments" not in json.loads(httpx_mock.get_requests()[0].content) + + def test_attachments_key_is_absent_when_empty_list( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[]) + + assert "attachments" not in json.loads(httpx_mock.get_requests()[0].content) + + def test_attachment_round_trips_the_wire_shape(self) -> None: + """The camelCase body the API emits parses back into the model unchanged.""" + wire = { + "id": _ATTACHMENT_ID, + "fileName": "a.csv", + "mimeType": "text/csv", + "url": "https://x/a.csv", + } + + parsed = GuardrailAttachment.model_validate(wire) + + assert parsed.file_name == "a.csv" + assert parsed.mime_type == "text/csv" + assert parsed.model_dump(by_alias=True) == wire + + +class TestGuardrailAttachmentTracing: + """The traced span must never carry an attachment's SAS url.""" + + def test_input_processor_redacts_urls_and_keeps_identity(self) -> None: + from uipath.platform.guardrails._guardrails_service import ( + _redact_attachment_urls, + ) + + inputs = { + "input_data": "see attached", + "guardrail": {"name": "Injection check"}, + "attachments": [ + { + "id": _ATTACHMENT_ID, + "fileName": "Tickets.csv", + "mimeType": "text/csv", + "url": "https://acct.blob.core.windows.net/c/Tickets.csv?sig=SECRET", + } + ], + } + + processed = _redact_attachment_urls(inputs) + + assert "SECRET" not in json.dumps(processed) + assert processed["attachments"][0]["url"] == "" + assert processed["attachments"][0]["fileName"] == "Tickets.csv" + assert processed["input_data"] == "see attached" + # Never mutates the caller's dict. + assert "SECRET" in json.dumps(inputs) + + def test_input_processor_is_a_noop_without_attachments(self) -> None: + from uipath.platform.guardrails._guardrails_service import ( + _redact_attachment_urls, + ) + + inputs = {"input_data": "x", "guardrail": {}} + + assert _redact_attachment_urls(inputs) == inputs + assert _redact_attachment_urls({**inputs, "attachments": None}) == { + **inputs, + "attachments": None, + } + + def test_evaluate_guardrail_forwards_a_longer_timeout_with_attachments( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """The default client timeout is 30s; a validate call that waits on the backend + fetching files gets 60s. Without this assertion the kwarg could vanish silently.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()]) + + timeout = httpx_mock.get_requests()[0].extensions["timeout"] + assert timeout["read"] == 60.0 + + def test_evaluate_guardrail_keeps_default_timeout_without_attachments( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, + ) + + service.evaluate_guardrail("x", _judge_guardrail()) + + timeout = httpx_mock.get_requests()[0].extensions["timeout"] + assert timeout["read"] != 60.0 diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 93559e572..183e9462b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.28" +version = "0.2.29" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d62b3c81c..6ecdc608c 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.28" +version = "0.2.29" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },