1717from uipath .agent .models .agent import (
1818 AgentInternalToolResourceConfig ,
1919)
20+ from uipath .core .feature_flags import FeatureFlags
2021from uipath .eval .mocks import mockable
2122from 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+ )
2231from uipath .runtime .errors import UiPathErrorCategory
2332
2433from uipath_langchain .agent .exceptions import (
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
51172def 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 )
0 commit comments