Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.28"
version = "0.2.29"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 49b5032 — all three lockfiles relocked; CI's uv lock --check is green.

🤖 Generated with Claude Code

description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
BYO_VALIDATOR_TYPE,
BuiltInValidatorGuardrail,
EnumListParameterValue,
GuardrailAttachment,
GuardrailType,
MapEnumParameterValue,
)
Expand All @@ -56,6 +57,7 @@
"GuardrailsService",
# Guardrail models
"BYO_VALIDATOR_TYPE",
"GuardrailAttachment",
"BuiltInValidatorGuardrail",
"GuardrailType",
"GuardrailValidationResultType",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<redacted>"})
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.
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e831652: an input_processor redacts attachments[*].url before the span records inputs, leaving payload, guardrail and attachment identity intact. Two tests cover it, including a no-mutation check.

🤖 Generated with Claude Code

) -> 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.
Expand All @@ -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"),
Expand All @@ -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
Comment on lines +195 to +196

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e831652: one test asserts the attachment-bearing call forwards timeout=60.0 (via httpx_mock request extensions), and one asserts the default path does not.

🤖 Generated with Claude Code


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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
195 changes: 195 additions & 0 deletions packages/uipath-platform/tests/services/test_guardrails_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from uipath.platform.guardrails import (
BuiltInValidatorGuardrail,
EnumListParameterValue,
GuardrailAttachment,
GuardrailsService,
MapEnumParameterValue,
)
Expand Down Expand Up @@ -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"] == "<redacted>"
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
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading