From 78d5aa96bca6f17479da2a46563095386b6b9054 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Sun, 13 Sep 2026 01:24:48 +0300 Subject: [PATCH 1/4] 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. Backward compatible: without `attachments` — or with an empty list — the request body is byte-identical to today, so an older backend is unaffected. uipath-platform: 31 tests pass (+4); ruff, ruff format and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../uipath/platform/guardrails/__init__.py | 2 + .../guardrails/_guardrails_service.py | 31 ++++- .../uipath/platform/guardrails/guardrails.py | 24 ++++ .../tests/services/test_guardrails_service.py | 111 ++++++++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) 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..cfa6c5fcb 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,15 @@ 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 # 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. @@ -102,12 +110,18 @@ 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 +141,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 +163,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..ed996e3ac 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,113 @@ 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 From 63da69ea64238eadeb0c4142dbe816836075157e Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Sun, 13 Sep 2026 01:43:25 +0300 Subject: [PATCH 2/4] chore: bump uipath-platform to 0.2.29 Carries the GuardrailAttachment model and the `attachments` argument on GuardrailsService.evaluate_guardrail. Co-Authored-By: Claude Opus 5 (1M context) --- packages/uipath-platform/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 49b50329b78435437839ec3ff66f620185ed8d7f Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Mon, 14 Sep 2026 16:53:19 +0300 Subject: [PATCH 3/4] chore: relock after the uipath-platform version bump CI runs `uv lock --check`; bumping the version in pyproject.toml without regenerating the lockfiles left both packages/uipath-platform/uv.lock and packages/uipath/uv.lock stale (uipath resolves uipath-platform through [tool.uv.sources]). ruff check and ruff format clean across all three packages; guardrails service tests still 31 passed. Co-Authored-By: Claude Opus 5 (1M context) --- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" }, From e831652cc11f7e16e8c45fc1ba67a0411eb23af6 Mon Sep 17 00:00:00 2001 From: Andrei Petraru Date: Mon, 14 Sep 2026 18:18:25 +0300 Subject: [PATCH 4/4] fix: keep attachment SAS urls out of the evaluate_guardrail trace span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @traced records a function's arguments on the OpenTelemetry span by default, so every GuardrailAttachment.url — a short-lived SAS credential — was landing in input.value. Flagged by Copilot on #1895. An input_processor now replaces attachments[*].url with "" and leaves everything else (payload, guardrail, attachment identity) intact; that is tighter than hide_input=True, which would drop the useful part of the span too. Also adds the two tests Copilot noted were missing: that an attachment-bearing evaluation forwards the 60s timeout, and that the default path does not. uipath-platform: 35 tests pass (+4); ruff, ruff format, mypy clean. Co-Authored-By: Claude Fable 5.1 --- .../guardrails/_guardrails_service.py | 24 +++++- .../tests/services/test_guardrails_service.py | 84 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) 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 cfa6c5fcb..ee00805b6 100644 --- a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -27,6 +27,26 @@ #: 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. _TRACEPARENT_PATTERN = re.compile( @@ -105,7 +125,9 @@ 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], diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py index ed996e3ac..a85f1b515 100644 --- a/packages/uipath-platform/tests/services/test_guardrails_service.py +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -1008,3 +1008,87 @@ def test_attachment_round_trips_the_wire_shape(self) -> None: 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