Skip to content

Commit 9ff96d1

Browse files
yashwagle1claude
andauthored
feat: gate analyze-files PII masking on pii-detection-scope (#845)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9638381 commit 9ff96d1

4 files changed

Lines changed: 193 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.10.22"
3+
version = "0.10.23"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ def _config_with_llm_call_attachments(
158158
return new_config
159159

160160

161+
# Policy scope values that include files in PII detection. The analyze-files
162+
# tool is a files-only flow, so masking runs only when files are in scope.
163+
_PII_FILE_SCOPES = frozenset({"Both", "Files"})
164+
165+
166+
def _is_pii_scope_for_files(policy: dict[str, Any] | None) -> bool:
167+
"""Return True when the policy's ``pii-detection-scope`` covers files."""
168+
if not policy:
169+
return False
170+
return policy.get("data", {}).get("pii-detection-scope") in _PII_FILE_SCOPES
171+
172+
161173
def _emit_pii_masking_attachments(span: otel_trace.Span, files: list[FileInfo]) -> None:
162174
"""Emit originals (IN) and masked copies (OUT) on the given PII Masking span.
163175
@@ -267,7 +279,11 @@ async def tool_fn(**kwargs: Any):
267279
logger.exception("Failed to fetch deployed policy")
268280

269281
masker: PiiMasker | None = None
270-
if client is not None and PiiMasker.is_policy_enabled(policy):
282+
if (
283+
client is not None
284+
and PiiMasker.is_policy_enabled(policy)
285+
and _is_pii_scope_for_files(policy)
286+
):
271287
# Reconcile OTel current span with the LangChain/LangGraph external
272288
# span provider so the new span is parented under the active tool
273289
# call span and shares its trace id.

tests/agent/tools/internal_tools/test_analyze_files_tool.py

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ANALYZE_FILES_SYSTEM_MESSAGE,
2424
LLM_CALL_ATTACHMENTS_METADATA_KEY,
2525
_config_with_llm_call_attachments,
26+
_is_pii_scope_for_files,
2627
_resolve_job_attachment_arguments,
2728
create_analyze_file_tool,
2829
)
@@ -648,9 +649,15 @@ async def test_invokes_masker_when_policy_enabled(
648649
resource_config,
649650
mock_llm,
650651
):
652+
policy = {
653+
"data": {
654+
"container": {"pii-in-flight-agents": True},
655+
"pii-detection-scope": "Both",
656+
}
657+
}
651658
mock_client = Mock()
652659
mock_client.automation_ops.get_deployed_policy_async = AsyncMock(
653-
return_value={"data": {"container": {"pii-in-flight-agents": True}}}
660+
return_value=policy
654661
)
655662
mock_uipath_cls.return_value = mock_client
656663

@@ -692,12 +699,8 @@ async def test_invokes_masker_when_policy_enabled(
692699
analysisTask="contact john@example.com", attachments=[attachment]
693700
)
694701

695-
mock_masker_cls.is_policy_enabled.assert_called_once_with(
696-
{"data": {"container": {"pii-in-flight-agents": True}}}
697-
)
698-
mock_masker_cls.assert_called_once_with(
699-
mock_client, {"data": {"container": {"pii-in-flight-agents": True}}}
700-
)
702+
mock_masker_cls.is_policy_enabled.assert_called_once_with(policy)
703+
mock_masker_cls.assert_called_once_with(mock_client, policy)
701704
masker_instance.apply.assert_awaited_once()
702705
masker_instance.rehydrate.assert_called_once_with("Sent to [EMAIL]")
703706

@@ -779,7 +782,12 @@ async def test_raises_agent_runtime_error_when_masker_apply_fails(
779782
):
780783
mock_client = Mock()
781784
mock_client.automation_ops.get_deployed_policy_async = AsyncMock(
782-
return_value={"data": {"container": {"pii-in-flight-agents": True}}}
785+
return_value={
786+
"data": {
787+
"container": {"pii-in-flight-agents": True},
788+
"pii-detection-scope": "Both",
789+
}
790+
}
783791
)
784792
mock_uipath_cls.return_value = mock_client
785793

@@ -814,6 +822,164 @@ async def test_raises_agent_runtime_error_when_masker_apply_fails(
814822
mock_llm.ainvoke.assert_not_called()
815823
mock_add_files.assert_not_called()
816824

825+
@patch(
826+
"uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper"
827+
)
828+
@patch(
829+
"uipath_langchain.agent.tools.internal_tools.analyze_files_tool.add_files_to_message"
830+
)
831+
@patch(
832+
"uipath_langchain.agent.tools.internal_tools.analyze_files_tool._resolve_job_attachment_arguments"
833+
)
834+
@patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.PiiMasker")
835+
@patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.UiPath")
836+
async def test_skips_masker_when_scope_excludes_files(
837+
self,
838+
mock_uipath_cls,
839+
mock_masker_cls,
840+
mock_resolve_attachments,
841+
mock_add_files,
842+
mock_get_wrapper,
843+
resource_config,
844+
mock_llm,
845+
):
846+
"""is_policy_enabled returns True, but scope is Prompts only — masker must be skipped."""
847+
mock_client = Mock()
848+
mock_client.automation_ops.get_deployed_policy_async = AsyncMock(
849+
return_value={
850+
"data": {
851+
"container": {"pii-in-flight-agents": True},
852+
"pii-detection-scope": "Prompts",
853+
}
854+
}
855+
)
856+
mock_uipath_cls.return_value = mock_client
857+
mock_masker_cls.is_policy_enabled = Mock(return_value=True)
858+
859+
mock_resolve_attachments.return_value = [
860+
FileInfo(
861+
url="https://orig/doc.pdf",
862+
name="doc.pdf",
863+
mime_type="application/pdf",
864+
)
865+
]
866+
mock_add_files.return_value = HumanMessage(content="task")
867+
mock_get_wrapper.return_value = Mock()
868+
869+
tool = create_analyze_file_tool(resource_config, mock_llm)
870+
attachment = MockAttachment(
871+
ID=str(uuid.uuid4()), FullName="doc.pdf", MimeType="application/pdf"
872+
)
873+
874+
assert tool.coroutine is not None
875+
await tool.coroutine(analysisTask="task", attachments=[attachment])
876+
877+
mock_masker_cls.assert_not_called()
878+
879+
@patch(
880+
"uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper"
881+
)
882+
@patch(
883+
"uipath_langchain.agent.tools.internal_tools.analyze_files_tool.add_files_to_message"
884+
)
885+
@patch(
886+
"uipath_langchain.agent.tools.internal_tools.analyze_files_tool._resolve_job_attachment_arguments"
887+
)
888+
@patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.PiiMasker")
889+
@patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.UiPath")
890+
async def test_invokes_masker_when_scope_is_files_only(
891+
self,
892+
mock_uipath_cls,
893+
mock_masker_cls,
894+
mock_resolve_attachments,
895+
mock_add_files,
896+
mock_get_wrapper,
897+
resource_config,
898+
mock_llm,
899+
):
900+
"""Scope == 'Files' should be sufficient to enable masking in the files flow."""
901+
policy = {
902+
"data": {
903+
"container": {"pii-in-flight-agents": True},
904+
"pii-detection-scope": "Files",
905+
}
906+
}
907+
mock_client = Mock()
908+
mock_client.automation_ops.get_deployed_policy_async = AsyncMock(
909+
return_value=policy
910+
)
911+
mock_uipath_cls.return_value = mock_client
912+
913+
mock_masker_cls.is_policy_enabled = Mock(return_value=True)
914+
masker_instance = Mock()
915+
masker_instance.apply = AsyncMock(
916+
return_value=(
917+
"task",
918+
[
919+
FileInfo(
920+
url="https://redacted/doc.pdf",
921+
name="pii_masked_doc.pdf",
922+
mime_type="application/pdf",
923+
)
924+
],
925+
)
926+
)
927+
masker_instance.rehydrate = Mock(return_value="result")
928+
mock_masker_cls.return_value = masker_instance
929+
930+
mock_resolve_attachments.return_value = [
931+
FileInfo(
932+
url="https://orig/doc.pdf",
933+
name="doc.pdf",
934+
mime_type="application/pdf",
935+
)
936+
]
937+
mock_add_files.return_value = HumanMessage(content="task")
938+
mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content="result"))
939+
mock_get_wrapper.return_value = Mock()
940+
941+
tool = create_analyze_file_tool(resource_config, mock_llm)
942+
attachment = MockAttachment(
943+
ID=str(uuid.uuid4()), FullName="doc.pdf", MimeType="application/pdf"
944+
)
945+
946+
assert tool.coroutine is not None
947+
await tool.coroutine(analysisTask="task", attachments=[attachment])
948+
949+
mock_masker_cls.assert_called_once_with(mock_client, policy)
950+
masker_instance.apply.assert_awaited_once()
951+
952+
953+
class TestIsPiiScopeForFiles:
954+
"""Tests for the _is_pii_scope_for_files policy gate."""
955+
956+
def test_returns_true_when_scope_is_both(self) -> None:
957+
policy = {"data": {"pii-detection-scope": "Both"}}
958+
assert _is_pii_scope_for_files(policy) is True
959+
960+
def test_returns_true_when_scope_is_files(self) -> None:
961+
policy = {"data": {"pii-detection-scope": "Files"}}
962+
assert _is_pii_scope_for_files(policy) is True
963+
964+
def test_returns_false_when_scope_is_prompts(self) -> None:
965+
policy = {"data": {"pii-detection-scope": "Prompts"}}
966+
assert _is_pii_scope_for_files(policy) is False
967+
968+
def test_returns_false_when_scope_missing(self) -> None:
969+
assert _is_pii_scope_for_files({"data": {}}) is False
970+
971+
def test_returns_false_when_policy_is_none(self) -> None:
972+
assert _is_pii_scope_for_files(None) is False
973+
974+
def test_returns_false_when_policy_is_empty(self) -> None:
975+
assert _is_pii_scope_for_files({}) is False
976+
977+
def test_is_case_sensitive(self) -> None:
978+
"""Policy serializes scope as 'Both' / 'Files' — lowercase shouldn't match."""
979+
assert (
980+
_is_pii_scope_for_files({"data": {"pii-detection-scope": "both"}}) is False
981+
)
982+
817983

818984
class TestConfigWithLlmCallAttachments:
819985
"""The attachments payload travels to the llmCall span via langchain config metadata."""

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)