Skip to content

Commit ffff174

Browse files
committed
feat: add PII masking for analyze-files tool
1 parent fa3d77e commit ffff174

3 files changed

Lines changed: 152 additions & 12 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.11"
77
dependencies = [
88
"uipath>=2.10.53, <2.11.0",
99
"uipath-core>=0.5.2, <0.6.0",
10-
"uipath-platform>=0.1.30, <0.2.0",
10+
"uipath-platform>=0.1.34, <0.2.0",
1111
"uipath-runtime>=0.10.0, <0.11.0",
1212
"langgraph>=1.1.8, <2.0.0",
1313
"langchain-core>=1.2.11, <2.0.0",

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

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,17 @@
1717
from uipath.agent.models.agent import (
1818
AgentInternalToolResourceConfig,
1919
)
20+
from uipath.core.feature_flags import FeatureFlags
2021
from uipath.eval.mocks import mockable
2122
from uipath.platform import UiPath
23+
from uipath.platform.semantic_proxy import (
24+
PiiDetectionRequest,
25+
PiiDetectionResponse,
26+
PiiDocument,
27+
PiiEntityThreshold,
28+
PiiFile,
29+
rehydrate_from_pii_response,
30+
)
2231
from uipath.runtime.errors import UiPathErrorCategory
2332

2433
from uipath_langchain.agent.exceptions import (
@@ -47,6 +56,118 @@
4756
"based on the extracted information."
4857
)
4958

59+
_PII_MASKING_FEATURE_FLAG = "File-Pii-Masking-Enabled"
60+
61+
62+
def is_pii_policy_enabled(policy: dict[str, Any] | None) -> bool:
63+
"""Determine whether PII detection should run.
64+
65+
Two gates (both must allow):
66+
67+
1. Local kill-switch — the ``File-Pii-Masking-Enabled`` feature flag
68+
(defaults to ``True``; override via ``FeatureFlags.configure_flags``
69+
or ``UIPATH_FEATURE_File-Pii-Masking-Enabled`` env var).
70+
2. Platform policy — ``data.container.pii-in-flight-agents`` from the
71+
AutomationOps deployed-policy response.
72+
"""
73+
if not FeatureFlags.is_flag_enabled(_PII_MASKING_FEATURE_FLAG, default=True):
74+
return False
75+
if not policy:
76+
return False
77+
container = policy.get("data", {}).get("container", {})
78+
return bool(container.get("pii-in-flight-agents", False))
79+
80+
81+
def _build_entity_thresholds_from_policy(
82+
policy: dict[str, Any] | None,
83+
) -> list[PiiEntityThreshold]:
84+
"""Extract enabled entity thresholds from the deployed policy.
85+
86+
Filters ``data.pii-entity-table`` to entries where ``pii-entity-is-enabled``
87+
is true and maps them to ``PiiEntityThreshold`` objects.
88+
"""
89+
if not policy:
90+
return []
91+
table = policy.get("data", {}).get("pii-entity-table", [])
92+
thresholds: list[PiiEntityThreshold] = []
93+
for entry in table:
94+
if not entry.get("pii-entity-is-enabled", False):
95+
continue
96+
category = entry.get("pii-entity-category")
97+
confidence = entry.get("pii-entity-confidence-threshold")
98+
if category is None or confidence is None:
99+
continue
100+
thresholds.append(
101+
PiiEntityThreshold(
102+
category=category,
103+
confidence_threshold=confidence,
104+
)
105+
)
106+
return thresholds
107+
108+
109+
def _rename_for_masking(file: FileInfo, redacted_url: str) -> FileInfo:
110+
"""Return a new ``FileInfo`` pointing at the redacted URL with a ``pii_masked_`` name prefix."""
111+
if "." in file.name:
112+
base, ext = file.name.rsplit(".", 1)
113+
new_name = f"pii_masked_{base}.{ext}"
114+
else:
115+
new_name = f"pii_masked_{file.name}"
116+
return FileInfo(url=redacted_url, name=new_name, mime_type=file.mime_type)
117+
118+
119+
async def _apply_pii_masking(
120+
client: UiPath,
121+
policy: dict[str, Any] | None,
122+
analysis_task: str,
123+
files: list[FileInfo],
124+
) -> tuple[str, list[FileInfo], PiiDetectionResponse]:
125+
"""Run PII detection and return a masked prompt, redacted files, and the raw response.
126+
127+
The returned response is retained so the LLM output can be rehydrated via
128+
:func:`rehydrate_from_pii_response` after inference.
129+
"""
130+
pii_request = PiiDetectionRequest(
131+
documents=[PiiDocument(id="user-prompt", role="user", document=analysis_task)],
132+
files=[
133+
PiiFile(
134+
file_name=f.name,
135+
file_url=f.url,
136+
file_type=f.name.rsplit(".", 1)[-1].lower() if "." in f.name else "",
137+
)
138+
for f in files
139+
],
140+
entity_thresholds=_build_entity_thresholds_from_policy(policy) or None,
141+
)
142+
pii_result = await client.semantic_proxy.detect_pii_async(pii_request)
143+
logger.info(
144+
"PII detection completed: %d document entities, %d file entities",
145+
sum(len(d.pii_entities) for d in pii_result.response),
146+
sum(len(f.pii_entities) for f in pii_result.files),
147+
)
148+
149+
masked_prompt = analysis_task
150+
for doc in pii_result.response:
151+
if doc.id == "user-prompt":
152+
if doc.masked_document != analysis_task:
153+
logger.info(
154+
"User prompt masked (%d entities replaced)",
155+
len(doc.pii_entities),
156+
)
157+
masked_prompt = doc.masked_document
158+
break
159+
160+
redacted_by_name = {f.file_name: f.file_url for f in pii_result.files}
161+
if redacted_by_name:
162+
masked_files = [
163+
_rename_for_masking(f, redacted_by_name.get(f.name, f.url)) for f in files
164+
]
165+
logger.info("Renamed %d file(s) with pii_masked_ prefix", len(masked_files))
166+
else:
167+
masked_files = files
168+
169+
return masked_prompt, masked_files, pii_result
170+
50171

51172
def create_analyze_file_tool(
52173
resource: AgentInternalToolResourceConfig, llm: BaseChatModel
@@ -84,12 +205,21 @@ async def tool_fn(**kwargs: Any):
84205
if not files:
85206
return {"analysisResult": "No attachments provided to analyze."}
86207

208+
client = UiPath()
209+
policy: dict[str, Any] | None = None
87210
try:
88-
client = UiPath()
89-
deployed_policy = await client.automation_ops.get_deployed_policy_async()
90-
logger.info("AutomationOps deployed policy response: %s", deployed_policy)
211+
policy = await client.automation_ops.get_deployed_policy_async()
91212
except Exception:
92-
logger.exception("Failed to fetch AutomationOps deployed policy")
213+
logger.exception("Failed to fetch deployed policy")
214+
215+
pii_result: PiiDetectionResponse | None = None
216+
if is_pii_policy_enabled(policy):
217+
try:
218+
analysis_task, files, pii_result = await _apply_pii_masking(
219+
client, policy, analysis_task, files
220+
)
221+
except Exception as e:
222+
logger.error("PII detection raised: %r", e, exc_info=True)
93223

94224
try:
95225
human_message = HumanMessage(content=analysis_task)
@@ -112,6 +242,16 @@ async def tool_fn(**kwargs: Any):
112242
del messages, human_message_with_files, files
113243

114244
analysis_result = extract_text_content(result)
245+
246+
if pii_result is not None:
247+
try:
248+
rehydrated = rehydrate_from_pii_response(analysis_result, pii_result)
249+
if rehydrated != analysis_result:
250+
logger.info("Rehydrated LLM response with PII entities")
251+
analysis_result = rehydrated
252+
except Exception:
253+
logger.exception("Failed to rehydrate LLM response")
254+
115255
return {"analysisResult": analysis_result}
116256

117257
job_attachment_wrapper = get_job_attachment_wrapper(output_type=output_model)

uv.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)