Skip to content

Commit db3cd20

Browse files
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 <noreply@anthropic.com>
1 parent e475e89 commit db3cd20

3 files changed

Lines changed: 81 additions & 66 deletions

File tree

‎packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py‎

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
)
99
from uipath.core.tracing import traced
1010

11-
from uipath.platform.constants import HEADER_GUARDRAILS_SOURCE
11+
from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_GUARDRAILS_SOURCE
1212

1313
from ..chat.llm_trace_context import build_trace_context_headers
1414
from ..common._base_service import BaseService
15-
from ..common._config import UiPathApiConfig
15+
from ..common._config import UiPathApiConfig, UiPathConfig
1616
from ..common._execution_context import UiPathExecutionContext
1717
from ..common._job_context import header_job_key
1818
from ..common._models import Endpoint, RequestSpec
@@ -28,25 +28,6 @@
2828
_ATTACHMENT_VALIDATE_TIMEOUT_SECONDS = 60.0
2929

3030

31-
def _redact_attachment_urls(inputs: dict[str, Any]) -> dict[str, Any]:
32-
"""Strip SAS urls from the traced inputs of ``evaluate_guardrail``.
33-
34-
``@traced`` records a function's arguments on the span by default. An attachment
35-
``url`` is a short-lived SAS credential and must never reach telemetry, so replace
36-
it and keep the rest (guardrail, input, attachment identity) intact.
37-
"""
38-
attachments = inputs.get("attachments")
39-
if not isinstance(attachments, list):
40-
return inputs
41-
redacted = []
42-
for attachment in attachments:
43-
if isinstance(attachment, dict) and "url" in attachment:
44-
redacted.append({**attachment, "url": "<redacted>"})
45-
else:
46-
redacted.append(attachment)
47-
return {**inputs, "attachments": redacted}
48-
49-
5031
# x-uipath-traceparent-id header format: {version}-{trace_id}-{span_id}[-{trace_flags}]
5132
# Based on W3C traceparent but allows 16- or 32-hex span IDs.
5233
_TRACEPARENT_PATTERN = re.compile(
@@ -125,9 +106,7 @@ def _parse_result(result_str: str) -> GuardrailValidationResultType:
125106
# Fallback to validation_failed if unknown
126107
return GuardrailValidationResultType.VALIDATION_FAILED
127108

128-
@traced(
129-
"evaluate_guardrail", run_type="uipath", input_processor=_redact_attachment_urls
130-
)
109+
@traced("evaluate_guardrail", run_type="uipath")
131110
def evaluate_guardrail(
132111
self,
133112
input_data: str | dict[str, Any],
@@ -179,11 +158,18 @@ def evaluate_guardrail(
179158
execution_source = self._execution_context.execution_source
180159
if execution_source:
181160
source_headers[HEADER_GUARDRAILS_SOURCE] = execution_source
161+
# When attachments are present, tell helix which folder the run executed in
162+
# so it can resolve each attachment id through Orchestrator's folder-scoped
163+
# API. Only sent alongside attachments: it is meaningless otherwise.
164+
folder_headers: dict[str, str] = {}
165+
if attachments and UiPathConfig.folder_key:
166+
folder_headers[HEADER_FOLDER_KEY] = UiPathConfig.folder_key
182167
request_headers = {
183168
**(spec.headers or {}),
184169
**trace_headers,
185170
**source_headers,
186171
**header_job_key(),
172+
**folder_headers,
187173
}
188174
# The default client timeout is 30s (common/_http_config.py). A validate call
189175
# carrying attachments waits for the backend to fetch and decode each one, so give

‎packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,25 +97,28 @@ class BuiltInValidatorGuardrail(BaseGuardrail):
9797

9898

9999
class GuardrailAttachment(BaseModel):
100-
"""A file attached to the run that a guardrail may inspect.
100+
"""A reference to a file attached to the run that a guardrail may inspect.
101101
102102
Passed to [`GuardrailsService.evaluate_guardrail`][uipath.platform.guardrails.GuardrailsService.evaluate_guardrail]
103103
so the guardrails backend can read the file's contents rather than only its metadata.
104104
105+
Only the Orchestrator attachment id crosses the wire: helix resolves it through
106+
its own Orchestrator client (folder-scoped when the run's folder key is sent
107+
alongside), so Orchestrator's access control is what decides whether the file can
108+
be read — the runtime never resolves or forwards a signed URL itself.
109+
105110
Attributes:
106-
id: The job attachment id, as a string UUID. Used by the backend as an
107-
extraction cache key and for trace correlation.
111+
id: The Orchestrator attachment id, as a string UUID. Used by the backend to
112+
resolve the file through Orchestrator, as an extraction cache key, and for
113+
trace correlation.
108114
file_name: Original file name, shown to a judge model so it can name the
109115
offending file.
110116
mime_type: Original mime type. The backend decides what it can inspect.
111-
url: A short-lived SAS URL resolved by the runtime. This is a **credential**:
112-
never log it, never put it on a span.
113117
"""
114118

115119
id: str
116120
file_name: str = Field(alias="fileName")
117121
mime_type: str = Field(alias="mimeType")
118-
url: str
119122

120123
model_config = ConfigDict(populate_by_name=True)
121124

‎packages/uipath-platform/tests/services/test_guardrails_service.py‎

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,6 @@ def _attachment() -> GuardrailAttachment:
922922
id=_ATTACHMENT_ID,
923923
file_name="Tickets.csv",
924924
mime_type="text/csv",
925-
url="https://acct.blob.core.windows.net/c/Tickets.csv?sig=x",
926925
)
927926

928927

@@ -953,7 +952,6 @@ def test_attachments_are_sent_with_camel_case_aliases(
953952
"id": _ATTACHMENT_ID,
954953
"fileName": "Tickets.csv",
955954
"mimeType": "text/csv",
956-
"url": "https://acct.blob.core.windows.net/c/Tickets.csv?sig=x",
957955
}
958956
]
959957

@@ -1000,7 +998,6 @@ def test_attachment_round_trips_the_wire_shape(self) -> None:
1000998
"id": _ATTACHMENT_ID,
1001999
"fileName": "a.csv",
10021000
"mimeType": "text/csv",
1003-
"url": "https://x/a.csv",
10041001
}
10051002

10061003
parsed = GuardrailAttachment.model_validate(wire)
@@ -1010,48 +1007,77 @@ def test_attachment_round_trips_the_wire_shape(self) -> None:
10101007
assert parsed.model_dump(by_alias=True) == wire
10111008

10121009

1013-
class TestGuardrailAttachmentTracing:
1014-
"""The traced span must never carry an attachment's SAS url."""
1010+
class TestGuardrailAttachmentFolderHeader:
1011+
"""evaluate_guardrail tells helix which folder the run executed in so it can
1012+
resolve each attachment id through Orchestrator's folder-scoped API."""
10151013

1016-
def test_input_processor_redacts_urls_and_keeps_identity(self) -> None:
1017-
from uipath.platform.guardrails._guardrails_service import (
1018-
_redact_attachment_urls,
1014+
def test_folder_header_sent_when_folder_key_configured(
1015+
self,
1016+
httpx_mock: HTTPXMock,
1017+
service: GuardrailsService,
1018+
base_url: str,
1019+
org: str,
1020+
tenant: str,
1021+
monkeypatch: pytest.MonkeyPatch,
1022+
) -> None:
1023+
monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-key-123")
1024+
httpx_mock.add_response(
1025+
url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}",
1026+
status_code=200,
1027+
json={"result": "PASSED", "details": ""},
10191028
)
10201029

1021-
inputs = {
1022-
"input_data": "see attached",
1023-
"guardrail": {"name": "Injection check"},
1024-
"attachments": [
1025-
{
1026-
"id": _ATTACHMENT_ID,
1027-
"fileName": "Tickets.csv",
1028-
"mimeType": "text/csv",
1029-
"url": "https://acct.blob.core.windows.net/c/Tickets.csv?sig=SECRET",
1030-
}
1031-
],
1032-
}
1030+
service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()])
10331031

1034-
processed = _redact_attachment_urls(inputs)
1032+
request = httpx_mock.get_requests()[0]
1033+
assert request.headers["x-uipath-folderkey"] == "folder-key-123"
10351034

1036-
assert "SECRET" not in json.dumps(processed)
1037-
assert processed["attachments"][0]["url"] == "<redacted>"
1038-
assert processed["attachments"][0]["fileName"] == "Tickets.csv"
1039-
assert processed["input_data"] == "see attached"
1040-
# Never mutates the caller's dict.
1041-
assert "SECRET" in json.dumps(inputs)
1035+
def test_folder_header_absent_when_no_folder_key_configured(
1036+
self,
1037+
httpx_mock: HTTPXMock,
1038+
service: GuardrailsService,
1039+
base_url: str,
1040+
org: str,
1041+
tenant: str,
1042+
monkeypatch: pytest.MonkeyPatch,
1043+
) -> None:
1044+
monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False)
1045+
httpx_mock.add_response(
1046+
url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}",
1047+
status_code=200,
1048+
json={"result": "PASSED", "details": ""},
1049+
)
1050+
1051+
service.evaluate_guardrail("x", _judge_guardrail(), attachments=[_attachment()])
10421052

1043-
def test_input_processor_is_a_noop_without_attachments(self) -> None:
1044-
from uipath.platform.guardrails._guardrails_service import (
1045-
_redact_attachment_urls,
1053+
request = httpx_mock.get_requests()[0]
1054+
assert "x-uipath-folderkey" not in request.headers
1055+
1056+
def test_folder_header_absent_without_attachments_even_if_configured(
1057+
self,
1058+
httpx_mock: HTTPXMock,
1059+
service: GuardrailsService,
1060+
base_url: str,
1061+
org: str,
1062+
tenant: str,
1063+
monkeypatch: pytest.MonkeyPatch,
1064+
) -> None:
1065+
"""The header is meaningless without attachments, so it is never sent."""
1066+
monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-key-123")
1067+
httpx_mock.add_response(
1068+
url=f"{base_url}{org}{tenant}{_VALIDATE_PATH}",
1069+
status_code=200,
1070+
json={"result": "PASSED", "details": ""},
10461071
)
10471072

1048-
inputs = {"input_data": "x", "guardrail": {}}
1073+
service.evaluate_guardrail("x", _judge_guardrail())
10491074

1050-
assert _redact_attachment_urls(inputs) == inputs
1051-
assert _redact_attachment_urls({**inputs, "attachments": None}) == {
1052-
**inputs,
1053-
"attachments": None,
1054-
}
1075+
request = httpx_mock.get_requests()[0]
1076+
assert "x-uipath-folderkey" not in request.headers
1077+
1078+
1079+
class TestGuardrailAttachmentTiming:
1080+
"""Attachments still get the longer, file-fetching-aware timeout."""
10551081

10561082
def test_evaluate_guardrail_forwards_a_longer_timeout_with_attachments(
10571083
self,

0 commit comments

Comments
 (0)