From c070818896f5b67f666abeab0991eee986116a35 Mon Sep 17 00:00:00 2001 From: Yash Wagle Date: Tue, 12 May 2026 00:42:53 -0700 Subject: [PATCH 1/2] feat: gate analyze-files PII masking on pii-detection-scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masking only runs when the deployed policy's pii-detection-scope is "Both" or "Files", in addition to the existing feature-flag and pii-in-flight-agents gates. The scope check lives at the call site because the decision is flow-specific — a prompt-only caller would need a different scope set. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- .../internal_tools/analyze_files_tool.py | 18 +- .../internal_tools/test_analyze_files_tool.py | 180 +++++++++++++++++- uv.lock | 2 +- 4 files changed, 191 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c198fe2a5..69da33c84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.10.22" +version = "0.10.23" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py index 230996eb5..09ee54606 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py @@ -158,6 +158,18 @@ def _config_with_llm_call_attachments( return new_config +# Policy scope values that include files in PII detection. The analyze-files +# tool is a files-only flow, so masking runs only when files are in scope. +_PII_FILE_SCOPES = frozenset({"Both", "Files"}) + + +def _is_pii_scope_for_files(policy: dict[str, Any] | None) -> bool: + """Return True when the policy's ``pii-detection-scope`` covers files.""" + if not policy: + return False + return policy.get("data", {}).get("pii-detection-scope") in _PII_FILE_SCOPES + + def _emit_pii_masking_attachments(span: otel_trace.Span, files: list[FileInfo]) -> None: """Emit originals (IN) and masked copies (OUT) on the given PII Masking span. @@ -267,7 +279,11 @@ async def tool_fn(**kwargs: Any): logger.exception("Failed to fetch deployed policy") masker: PiiMasker | None = None - if client is not None and PiiMasker.is_policy_enabled(policy): + if ( + client is not None + and PiiMasker.is_policy_enabled(policy) + and _is_pii_scope_for_files(policy) + ): # Reconcile OTel current span with the LangChain/LangGraph external # span provider so the new span is parented under the active tool # call span and shares its trace id. diff --git a/tests/agent/tools/internal_tools/test_analyze_files_tool.py b/tests/agent/tools/internal_tools/test_analyze_files_tool.py index bf6fe10e6..daf441da0 100644 --- a/tests/agent/tools/internal_tools/test_analyze_files_tool.py +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -23,6 +23,7 @@ ANALYZE_FILES_SYSTEM_MESSAGE, LLM_CALL_ATTACHMENTS_METADATA_KEY, _config_with_llm_call_attachments, + _is_pii_scope_for_files, _resolve_job_attachment_arguments, create_analyze_file_tool, ) @@ -648,9 +649,15 @@ async def test_invokes_masker_when_policy_enabled( resource_config, mock_llm, ): + policy = { + "data": { + "container": {"pii-in-flight-agents": True}, + "pii-detection-scope": "Both", + } + } mock_client = Mock() mock_client.automation_ops.get_deployed_policy_async = AsyncMock( - return_value={"data": {"container": {"pii-in-flight-agents": True}}} + return_value=policy ) mock_uipath_cls.return_value = mock_client @@ -692,12 +699,8 @@ async def test_invokes_masker_when_policy_enabled( analysisTask="contact john@example.com", attachments=[attachment] ) - mock_masker_cls.is_policy_enabled.assert_called_once_with( - {"data": {"container": {"pii-in-flight-agents": True}}} - ) - mock_masker_cls.assert_called_once_with( - mock_client, {"data": {"container": {"pii-in-flight-agents": True}}} - ) + mock_masker_cls.is_policy_enabled.assert_called_once_with(policy) + mock_masker_cls.assert_called_once_with(mock_client, policy) masker_instance.apply.assert_awaited_once() masker_instance.rehydrate.assert_called_once_with("Sent to [EMAIL]") @@ -779,7 +782,12 @@ async def test_raises_agent_runtime_error_when_masker_apply_fails( ): mock_client = Mock() mock_client.automation_ops.get_deployed_policy_async = AsyncMock( - return_value={"data": {"container": {"pii-in-flight-agents": True}}} + return_value={ + "data": { + "container": {"pii-in-flight-agents": True}, + "pii-detection-scope": "Both", + } + } ) mock_uipath_cls.return_value = mock_client @@ -814,6 +822,162 @@ async def test_raises_agent_runtime_error_when_masker_apply_fails( mock_llm.ainvoke.assert_not_called() mock_add_files.assert_not_called() + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.add_files_to_message" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool._resolve_job_attachment_arguments" + ) + @patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.PiiMasker") + @patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.UiPath") + async def test_skips_masker_when_scope_excludes_files( + self, + mock_uipath_cls, + mock_masker_cls, + mock_resolve_attachments, + mock_add_files, + mock_get_wrapper, + resource_config, + mock_llm, + ): + """is_policy_enabled returns True, but scope is Prompts only — masker must be skipped.""" + mock_client = Mock() + mock_client.automation_ops.get_deployed_policy_async = AsyncMock( + return_value={ + "data": { + "container": {"pii-in-flight-agents": True}, + "pii-detection-scope": "Prompts", + } + } + ) + mock_uipath_cls.return_value = mock_client + mock_masker_cls.is_policy_enabled = Mock(return_value=True) + + mock_resolve_attachments.return_value = [ + FileInfo( + url="https://orig/doc.pdf", + name="doc.pdf", + mime_type="application/pdf", + ) + ] + mock_add_files.return_value = HumanMessage(content="task") + mock_get_wrapper.return_value = Mock() + + tool = create_analyze_file_tool(resource_config, mock_llm) + attachment = MockAttachment( + ID=str(uuid.uuid4()), FullName="doc.pdf", MimeType="application/pdf" + ) + + assert tool.coroutine is not None + await tool.coroutine(analysisTask="task", attachments=[attachment]) + + mock_masker_cls.assert_not_called() + + @patch( + "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool.add_files_to_message" + ) + @patch( + "uipath_langchain.agent.tools.internal_tools.analyze_files_tool._resolve_job_attachment_arguments" + ) + @patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.PiiMasker") + @patch("uipath_langchain.agent.tools.internal_tools.analyze_files_tool.UiPath") + async def test_invokes_masker_when_scope_is_files_only( + self, + mock_uipath_cls, + mock_masker_cls, + mock_resolve_attachments, + mock_add_files, + mock_get_wrapper, + resource_config, + mock_llm, + ): + """Scope == 'Files' should be sufficient to enable masking in the files flow.""" + policy = { + "data": { + "container": {"pii-in-flight-agents": True}, + "pii-detection-scope": "Files", + } + } + mock_client = Mock() + mock_client.automation_ops.get_deployed_policy_async = AsyncMock( + return_value=policy + ) + mock_uipath_cls.return_value = mock_client + + mock_masker_cls.is_policy_enabled = Mock(return_value=True) + masker_instance = Mock() + masker_instance.apply = AsyncMock( + return_value=( + "task", + [ + FileInfo( + url="https://redacted/doc.pdf", + name="pii_masked_doc.pdf", + mime_type="application/pdf", + ) + ], + ) + ) + masker_instance.rehydrate = Mock(return_value="result") + mock_masker_cls.return_value = masker_instance + + mock_resolve_attachments.return_value = [ + FileInfo( + url="https://orig/doc.pdf", + name="doc.pdf", + mime_type="application/pdf", + ) + ] + mock_add_files.return_value = HumanMessage(content="task") + mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content="result")) + mock_get_wrapper.return_value = Mock() + + tool = create_analyze_file_tool(resource_config, mock_llm) + attachment = MockAttachment( + ID=str(uuid.uuid4()), FullName="doc.pdf", MimeType="application/pdf" + ) + + assert tool.coroutine is not None + await tool.coroutine(analysisTask="task", attachments=[attachment]) + + mock_masker_cls.assert_called_once_with(mock_client, policy) + masker_instance.apply.assert_awaited_once() + + +class TestIsPiiScopeForFiles: + """Tests for the _is_pii_scope_for_files policy gate.""" + + def test_returns_true_when_scope_is_both(self) -> None: + policy = {"data": {"pii-detection-scope": "Both"}} + assert _is_pii_scope_for_files(policy) is True + + def test_returns_true_when_scope_is_files(self) -> None: + policy = {"data": {"pii-detection-scope": "Files"}} + assert _is_pii_scope_for_files(policy) is True + + def test_returns_false_when_scope_is_prompts(self) -> None: + policy = {"data": {"pii-detection-scope": "Prompts"}} + assert _is_pii_scope_for_files(policy) is False + + def test_returns_false_when_scope_missing(self) -> None: + assert _is_pii_scope_for_files({"data": {}}) is False + + def test_returns_false_when_policy_is_none(self) -> None: + assert _is_pii_scope_for_files(None) is False + + def test_returns_false_when_policy_is_empty(self) -> None: + assert _is_pii_scope_for_files({}) is False + + def test_is_case_sensitive(self) -> None: + """Policy serializes scope as 'Both' / 'Files' — lowercase shouldn't match.""" + assert _is_pii_scope_for_files({"data": {"pii-detection-scope": "both"}}) is False + class TestConfigWithLlmCallAttachments: """The attachments payload travels to the llmCall span via langchain config metadata.""" diff --git a/uv.lock b/uv.lock index 7f99c4320..5739ff5ff 100644 --- a/uv.lock +++ b/uv.lock @@ -4375,7 +4375,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.10.22" +version = "0.10.23" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From 0c681cc63f86502d9c57deb3afb8fe0c6610608c Mon Sep 17 00:00:00 2001 From: Yash Wagle Date: Tue, 12 May 2026 10:55:15 -0700 Subject: [PATCH 2/2] chore: ruff format test_analyze_files_tool.py Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/agent/tools/internal_tools/test_analyze_files_tool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/agent/tools/internal_tools/test_analyze_files_tool.py b/tests/agent/tools/internal_tools/test_analyze_files_tool.py index daf441da0..8fa13f79a 100644 --- a/tests/agent/tools/internal_tools/test_analyze_files_tool.py +++ b/tests/agent/tools/internal_tools/test_analyze_files_tool.py @@ -976,7 +976,9 @@ def test_returns_false_when_policy_is_empty(self) -> None: def test_is_case_sensitive(self) -> None: """Policy serializes scope as 'Both' / 'Files' — lowercase shouldn't match.""" - assert _is_pii_scope_for_files({"data": {"pii-detection-scope": "both"}}) is False + assert ( + _is_pii_scope_for_files({"data": {"pii-detection-scope": "both"}}) is False + ) class TestConfigWithLlmCallAttachments: