From ed631d30bbb473fc96a9f952e9da466af96db50f Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Wed, 16 Sep 2026 17:57:37 +0300 Subject: [PATCH 1/3] feat: allow guardrail evaluation to carry attachment references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `attachments` keyword to GuardrailsService.evaluate_guardrail and a GuardrailAttachment model (id, fileName, mimeType, url), so a caller can tell the guardrails backend which files a guardrail should inspect instead of the backend seeing only attachment metadata embedded in the payload string. Also forwards a 60s timeout when attachments are present. The default client timeout is 30s and RequestSpec.timeout was constructed but never passed, so a validate call that waits on server-side file fetching would have timed out. An attachment url is a short-lived SAS credential, and @traced records a function's arguments on the span by default, so an input_processor redacts attachments[*].url and leaves the rest of the span intact. Backward compatible: without `attachments` — or with an empty list — the request body is byte-identical to today, so an older backend is unaffected. The parameter is keyword-only and defaults to None, so existing callers are untouched. Bumps uipath-platform to 0.2.31. SDK_REFERENCE.md records the new parameter, which makes uipath a co-changed package, so it bumps to 2.14.21 and raises its floor to uipath-platform>=0.2.31. uipath-platform: 35 tests pass (+8); ruff, ruff format and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/guardrails/__init__.py | 2 + .../guardrails/_guardrails_service.py | 55 ++++- .../uipath/platform/guardrails/guardrails.py | 24 +++ .../tests/services/test_guardrails_service.py | 195 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_resources/SDK_REFERENCE.md | 2 +- packages/uipath/uv.lock | 2 +- 9 files changed, 277 insertions(+), 9 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 45f24aa63..c66fd8c42 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.30" +version = "0.2.31" 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 e719a4198..8affb138e 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.30" +version = "0.2.31" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index efa9ea4ef..661a878df 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.13.5, <0.14.0", - "uipath-platform>=0.2.30, <0.3.0", + "uipath-platform>=0.2.31, <0.3.0", "uipath-ipc>=2.5.1, <2.6.0", "click>=8.3.3, <9.0.0", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index 704dfa078..6494e9b0d 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -668,7 +668,7 @@ Guardrails service ```python # Validate input text using the provided guardrail. -sdk.guardrails.evaluate_guardrail(input_data: str | dict[str, Any], guardrail: uipath.platform.guardrails.guardrails.BuiltInValidatorGuardrail) -> uipath.core.guardrails.guardrails.GuardrailValidationResult +sdk.guardrails.evaluate_guardrail(input_data: str | dict[str, Any], guardrail: uipath.platform.guardrails.guardrails.BuiltInValidatorGuardrail, attachments: list[uipath.platform.guardrails.guardrails.GuardrailAttachment] | None=None) -> uipath.core.guardrails.guardrails.GuardrailValidationResult ``` diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 896d8a07b..fa8c132d5 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.30" +version = "0.2.31" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From e7390d27f4561e5e118def86da3987cbf0e6b6e4 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Thu, 17 Sep 2026 00:36:43 +0300 Subject: [PATCH 2/3] feat: reference guardrail attachments by id and forward the folder key Guardrail attachments are now referenced by their Orchestrator attachment id alone: GuardrailAttachment drops the `url` field the runtime used to resolve to a signed URL before sending it to the guardrails backend. Two problems with that: Orchestrator's signed URL shape is environment-dependent (a platform-proxied alpha.uipath.com URL on alpha, not *.blob.core.windows.net), so a host allow-list downstream silently dropped every attachment; and a caller-supplied URL meant the backend never verified the caller was entitled to the file it fetched. The backend now resolves each id through its own Orchestrator client, so Orchestrator's own access control applies. evaluate_guardrail sends the optional x-uipath-folderkey header (already defined as HEADER_FOLDER_KEY) alongside attachments when the SDK is configured with a folder key, so the backend can resolve folder-scoped attachments. The now-pointless URL-redaction input_processor on @traced is removed along with the field it protected. Co-Authored-By: Claude Fable 5.1 --- .../guardrails/_guardrails_service.py | 34 ++----- .../uipath/platform/guardrails/guardrails.py | 15 +-- .../tests/services/test_guardrails_service.py | 98 ++++++++++++------- 3 files changed, 81 insertions(+), 66 deletions(-) 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 ee00805b6..6a25e7bc4 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -8,11 +8,11 @@ ) from uipath.core.tracing import traced -from uipath.platform.constants import HEADER_GUARDRAILS_SOURCE +from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_GUARDRAILS_SOURCE from ..chat.llm_trace_context import build_trace_context_headers from ..common._base_service import BaseService -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec @@ -28,25 +28,6 @@ _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. _TRACEPARENT_PATTERN = re.compile( @@ -125,9 +106,7 @@ def _parse_result(result_str: str) -> GuardrailValidationResultType: # Fallback to validation_failed if unknown return GuardrailValidationResultType.VALIDATION_FAILED - @traced( - "evaluate_guardrail", run_type="uipath", input_processor=_redact_attachment_urls - ) + @traced("evaluate_guardrail", run_type="uipath") def evaluate_guardrail( self, input_data: str | dict[str, Any], @@ -179,11 +158,18 @@ def evaluate_guardrail( execution_source = self._execution_context.execution_source if execution_source: source_headers[HEADER_GUARDRAILS_SOURCE] = execution_source + # When attachments are present, tell helix which folder the run executed in + # so it can resolve each attachment id through Orchestrator's folder-scoped + # API. Only sent alongside attachments: it is meaningless otherwise. + folder_headers: dict[str, str] = {} + if attachments and UiPathConfig.folder_key: + folder_headers[HEADER_FOLDER_KEY] = UiPathConfig.folder_key request_headers = { **(spec.headers or {}), **trace_headers, **source_headers, **header_job_key(), + **folder_headers, } # 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 diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py index 8b860837f..c6935f361 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -97,25 +97,28 @@ class BuiltInValidatorGuardrail(BaseGuardrail): class GuardrailAttachment(BaseModel): - """A file attached to the run that a guardrail may inspect. + """A reference to 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. + Only the Orchestrator attachment id crosses the wire: helix resolves it through + its own Orchestrator client (folder-scoped when the run's folder key is sent + alongside), so Orchestrator's access control is what decides whether the file can + be read — the runtime never resolves or forwards a signed URL itself. + Attributes: - id: The job attachment id, as a string UUID. Used by the backend as an - extraction cache key and for trace correlation. + id: The Orchestrator attachment id, as a string UUID. Used by the backend to + resolve the file through Orchestrator, 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) diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index a85f1b515..95ba141ef 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -922,7 +922,6 @@ def _attachment() -> GuardrailAttachment: id=_ATTACHMENT_ID, file_name="Tickets.csv", mime_type="text/csv", - url="https://acct.blob.core.windows.net/c/Tickets.csv?sig=x", ) @@ -953,7 +952,6 @@ def test_attachments_are_sent_with_camel_case_aliases( "id": _ATTACHMENT_ID, "fileName": "Tickets.csv", "mimeType": "text/csv", - "url": "https://acct.blob.core.windows.net/c/Tickets.csv?sig=x", } ] @@ -1000,7 +998,6 @@ def test_attachment_round_trips_the_wire_shape(self) -> None: "id": _ATTACHMENT_ID, "fileName": "a.csv", "mimeType": "text/csv", - "url": "https://x/a.csv", } parsed = GuardrailAttachment.model_validate(wire) @@ -1010,48 +1007,77 @@ def test_attachment_round_trips_the_wire_shape(self) -> None: assert parsed.model_dump(by_alias=True) == wire -class TestGuardrailAttachmentTracing: - """The traced span must never carry an attachment's SAS url.""" +class TestGuardrailAttachmentFolderHeader: + """evaluate_guardrail tells helix which folder the run executed in so it can + resolve each attachment id through Orchestrator's folder-scoped API.""" - def test_input_processor_redacts_urls_and_keeps_identity(self) -> None: - from uipath.platform.guardrails._guardrails_service import ( - _redact_attachment_urls, + def test_folder_header_sent_when_folder_key_configured( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-key-123") + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, ) - 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", - } - ], - } + service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()]) - processed = _redact_attachment_urls(inputs) + request = httpx_mock.get_requests()[0] + assert request.headers["x-uipath-folderkey"] == "folder-key-123" - 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_folder_header_absent_when_no_folder_key_configured( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + 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()]) - def test_input_processor_is_a_noop_without_attachments(self) -> None: - from uipath.platform.guardrails._guardrails_service import ( - _redact_attachment_urls, + request = httpx_mock.get_requests()[0] + assert "x-uipath-folderkey" not in request.headers + + def test_folder_header_absent_without_attachments_even_if_configured( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The header is meaningless without attachments, so it is never sent.""" + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-key-123") + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}", + status_code=200, + json={"result": "PASSED", "details": ""}, ) - inputs = {"input_data": "x", "guardrail": {}} + service.evaluate_guardrail("x", _judge_guardrail()) - assert _redact_attachment_urls(inputs) == inputs - assert _redact_attachment_urls({**inputs, "attachments": None}) == { - **inputs, - "attachments": None, - } + request = httpx_mock.get_requests()[0] + assert "x-uipath-folderkey" not in request.headers + + +class TestGuardrailAttachmentTiming: + """Attachments still get the longer, file-fetching-aware timeout.""" def test_evaluate_guardrail_forwards_a_longer_timeout_with_attachments( self, From f7d65ad077b2318a9677084b415ac5296391e535 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Thu, 17 Sep 2026 08:25:25 +0300 Subject: [PATCH 3/3] chore: bump uipath to 2.14.22 main released 2.14.21 while this branch was carrying the same number, so check-version-availability rejected the PR. The package still has to move because SDK_REFERENCE.md changed and its uipath-platform floor is now 0.2.31. Co-Authored-By: Claude Fable 5.1 --- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 661a878df..7eea2b2c8 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.21" +version = "2.14.22" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index fa8c132d5..618d86085 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.21" +version = "2.14.22" source = { editable = "." } dependencies = [ { name = "applicationinsights" },